Hand the sign-in page to the editor through URL-mode elicitation

When the client advertises elicitation.url, `authenticate` sends it the authorize URL as an elicitation/create in URL mode instead of launching a browser from the agent. The user sees where they are sent, and sign-in works where sigit has no browser to launch. Declining or cancelling ends the sign-in, a client that fails the request falls back to opening the browser from here, and the client gets elicitation/complete once the code is redeemed. The elicitation is scoped to the authenticate request, since no session exists yet, so `authenticate` now runs in a spawned task where the client's answer can reach it. Form mode is never requested. The spec forbids collecting credentials in a form, and forwarding an MCP server's own elicitation/create needs a hook ed-mcp does not have yet. Closes #197 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 10, 2026 at 16:19 UTC 449baf7150ac8b344229089c1c2baf794df49fe3
3 files changed +387 -11
.agents/AGENTS.md
+9 -1
@@ -301,6 +301,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
301 HTTP entries are connected too, with the URL and headers the client sent, because
302 `handle_initialize` advertises `mcpCapabilities.http`. SSE is not advertised (the MCP spec
303 deprecated it), so a conforming client never sends one and a stray entry is skipped.
304 + An MCP server's `elicitation/create` is not forwarded to the ACP client as a form
305 + elicitation: `ed-mcp`'s client handler declares no capabilities and has no hook for it, so
306 + servers are never offered elicitation, and `rmcp` answers `ping` and refuses other server
307 + requests itself. Forwarding needs that hook in `ed-mcp` first.
308 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
309 `decision_for` before executing: read-only tools always run; mutating tools (and all
310 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
@@ -430,7 +434,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
434 resulting token goes into `credentials` like any other, and is served by `/api/v1/user` rather
435 than the deprecated `/api/v1/me` — OAuth tokens are rejected there. The scopes requested are
436 `user:read code:agent`; `code:agent` is what reaches `chat/completions` and the official MCP
433 - server. Surfaces: ACP `authenticate` (loopback only — nowhere to show a code), a bare `/login`
437 + server. Surfaces: ACP `authenticate` (loopback only, since a code cannot be pasted back; for a
438 + client that advertises `elicitation.url`, `sign_in_via_client` hands it the authorize URL as a
439 + URL-mode elicitation instead of launching a browser, ends the sign-in if the user declines, and
440 + sends `elicitation/complete` once the code is redeemed; `authenticate` runs in a spawned task
441 + so that answer can be routed), a bare `/login`
442 in either chat surface, and `sigit login` (which adds the paste fallback, plus `--paste` to
443 force it and `--password` for the old email/password prompt).
444 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
src/main.rs
+112 -10
@@ -96,6 +96,10 @@ use agent_client_protocol::schema::v1::{
96 ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate,
97 WaitForTerminalExitRequest, WriteTextFileRequest,
98 };
99 +use agent_client_protocol::schema::v1::{
100 + CompleteElicitationNotification, CreateElicitationRequest, ElicitationAction,
101 + ElicitationRequestScope, ElicitationUrlMode, RequestId,
102 +};
103 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
104 use onde::inference::{ChatEngine, GgufModelConfig};
105
@@ -2220,6 +2224,18 @@ impl SiGitAgent {
2224 client_terminal::register(Arc::new(AcpClientTerminal { cx: cx.clone() }), terminal);
2225 }
2226
2227 + // URL-mode elicitation is where `authenticate` sends the user to sign
2228 + // in. Form mode is never used: the spec forbids collecting
2229 + // credentials in a form, and nothing else here asks the user for
2230 + // structured input.
2231 + let elicit_url = req
2232 + .client_capabilities
2233 + .elicitation
2234 + .as_ref()
2235 + .is_some_and(|caps| caps.url.is_some());
2236 + log::info!("elicitation: url={elicit_url}");
2237 + CLIENT_ELICITATION_URL.store(elicit_url, Ordering::Relaxed);
2238 +
2239 // A client that renders boolean config options gets the Inference
2240 // switch as a toggle; the rest keep the two-value select.
2241 let boolean_options = req
@@ -2305,8 +2321,14 @@ impl SiGitAgent {
2321 .meta(initialize_meta()))
2322 }
2323
2324 + /// ACP `authenticate`. Runs in a spawned task: with URL-mode elicitation
2325 + /// it waits on the client's answer, which the dispatch loop has to be free
2326 + /// to deliver. `request_id` is the request's own id, which scopes that
2327 + /// elicitation, since no session exists yet.
2328 async fn handle_authenticate(
2329 &self,
2330 + cx: &ConnectionTo<Client>,
2331 + request_id: RequestId,
2332 req: AuthenticateRequest,
2333 ) -> agent_client_protocol::Result<AuthenticateResponse> {
2334 log::info!("authenticate: method={}", req.method_id.0);
@@ -2319,9 +2341,10 @@ impl SiGitAgent {
2341 }
2342
2343 // Otherwise run the browser flow. There is no way to collect a password
2322 - // here (the method has no UI of its own) and no way to show a code to
2323 - // paste, so this is the loopback variant or nothing: the editor gets an
2324 - // error naming the terminal command that does work without a listener.
2344 + // here (the spec forbids asking for credentials in a form) and no way
2345 + // to take a pasted code back, so this is the loopback variant or
2346 + // nothing: the editor gets an error naming the terminal command that
2347 + // does work without a listener.
2348 let flow = browser_auth::begin();
2349 if !flow.is_loopback() {
2350 return Err(agent_client_protocol::Error::new(
@@ -2332,12 +2355,17 @@ impl SiGitAgent {
2355 ));
2356 }
2357
2335 - log::info!("authenticate: opening browser at {}", flow.authorize_url());
2336 - if !browser_auth::open_browser(flow.authorize_url()) {
2337 - log::warn!("authenticate: no browser could be launched");
2338 - }
2358 + let outcome = if CLIENT_ELICITATION_URL.load(Ordering::Relaxed) {
2359 + sign_in_via_client(cx, request_id, flow).await
2360 + } else {
2361 + log::info!("authenticate: opening browser at {}", flow.authorize_url());
2362 + if !browser_auth::open_browser(flow.authorize_url()) {
2363 + log::warn!("authenticate: no browser could be launched");
2364 + }
2365 + browser_auth::complete_loopback(flow).await
2366 + };
2367
2340 - match browser_auth::complete_loopback(flow).await {
2368 + match outcome {
2369 Ok(email) => {
2370 log::info!("authenticate: signed in as {email}");
2371 Ok(AuthenticateResponse::default())
@@ -4817,6 +4845,10 @@ const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference";
4845 /// process serves one ACP connection, so this is set once in `initialize`.
4846 static CLIENT_BOOLEAN_CONFIG_OPTIONS: AtomicBool = AtomicBool::new(false);
4847
4848 +/// Whether the connected client advertised URL-mode elicitation, which
4849 +/// `authenticate` uses to hand the sign-in page to the editor.
4850 +static CLIENT_ELICITATION_URL: AtomicBool = AtomicBool::new(false);
4851 +
4852 /// `select` value ids for the Local Inference toggle.
4853 const LOCAL_INFERENCE_ON: &str = "local-inference-on";
4854 const LOCAL_INFERENCE_OFF: &str = "local-inference-off";
@@ -6002,8 +6034,16 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
6034 .on_receive_request(
6035 {
6036 let state = Arc::clone(&state);
6005 - async move |req: AuthenticateRequest, responder, _cx: ConnectionTo<Client>| {
6006 - handle_response(responder, state.handle_authenticate(req).await)
6037 + async move |req: AuthenticateRequest, responder, cx: ConnectionTo<Client>| {
6038 + let state = Arc::clone(&state);
6039 + let task_cx = cx.clone();
6040 + let request_id = responder.id().clone();
6041 + cx.spawn(async move {
6042 + handle_response(
6043 + responder,
6044 + state.handle_authenticate(&task_cx, request_id, req).await,
6045 + )
6046 + })
6047 }
6048 },
6049 agent_client_protocol::on_receive_request!(),
@@ -6202,6 +6242,68 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
6242 Ok(())
6243 }
6244
6245 +/// The loopback sign-in with the editor showing the page: the authorize URL
6246 +/// goes to the client as a URL-mode elicitation, so the user sees where they
6247 +/// are being sent and the editor opens it, which also works when this process
6248 +/// cannot launch a browser itself. A user who declines ends the sign-in; a
6249 +/// client that fails the request gets the old behaviour, a browser opened
6250 +/// from here. Once the code is redeemed the client is told the elicitation
6251 +/// is complete.
6252 +async fn sign_in_via_client(
6253 + cx: &ConnectionTo<Client>,
6254 + request_id: RequestId,
6255 + flow: browser_auth::Flow,
6256 +) -> Result<String, String> {
6257 + let url = flow.authorize_url().to_string();
6258 + let elicitation_id = uuid::Uuid::new_v4().to_string();
6259 + log::info!("authenticate: asking the client to open {url}");
6260 + let ask = cx
6261 + .send_request(CreateElicitationRequest::new(
6262 + ElicitationUrlMode::new(
6263 + ElicitationRequestScope::new(request_id),
6264 + elicitation_id.clone(),
6265 + url.clone(),
6266 + ),
6267 + "Sign in to siGit Code in your browser to use siGit Code Cloud.",
6268 + ))
6269 + .block_task();
6270 + let sign_in = browser_auth::complete_loopback(flow);
6271 + tokio::pin!(sign_in);
6272 +
6273 + // The page can be finished before the client gets round to answering.
6274 + enum First {
6275 + Answer(
6276 + agent_client_protocol::Result<
6277 + agent_client_protocol::schema::v1::CreateElicitationResponse,
6278 + >,
6279 + ),
6280 + SignedIn(Result<String, String>),
6281 + }
6282 + let first = tokio::select! {
6283 + answer = ask => First::Answer(answer),
6284 + result = &mut sign_in => First::SignedIn(result),
6285 + };
6286 + let result = match first {
6287 + First::SignedIn(result) => result,
6288 + First::Answer(Ok(response)) => match response.action {
6289 + ElicitationAction::Accept(_) => sign_in.await,
6290 + _ => Err("the sign-in page was not opened".to_string()),
6291 + },
6292 + First::Answer(Err(error)) => {
6293 + log::warn!("authenticate: the client could not show the sign-in page ({error})");
6294 + if !browser_auth::open_browser(&url) {
6295 + log::warn!("authenticate: no browser could be launched");
6296 + }
6297 + sign_in.await
6298 + }
6299 + };
6300 + if result.is_ok() {
6301 + cx.send_notification(CompleteElicitationNotification::new(elicitation_id))
6302 + .ok();
6303 + }
6304 + result
6305 +}
6306 +
6307 /// Run the browser sign-in from a chat surface and describe how it went.
6308 ///
6309 /// Both the TUI and an ACP session reach this from a bare `/login`. Neither can
tests/acp_elicitation.rs new
+266
@@ -0,0 +1,266 @@
1 +//! `authenticate` through a URL-mode elicitation (issue #197).
2 +//!
3 +//! A client that advertises `elicitation.url` is handed the sign-in page
4 +//! instead of sigit launching a browser itself. The test plays the client and
5 +//! the browser: it answers the `elicitation/create`, follows the authorize URL
6 +//! back to sigit's loopback listener, and serves the token and profile
7 +//! endpoints from a fake siGit Code Cloud.
8 +
9 +use std::io::{BufRead, BufReader, Read, Write};
10 +use std::net::{TcpListener, TcpStream};
11 +use std::process::{Child, ChildStdin, Command, Stdio};
12 +use std::sync::mpsc::{Receiver, channel};
13 +use std::time::{Duration, Instant};
14 +
15 +use serde_json::{Value, json};
16 +
17 +const TIMEOUT: Duration = Duration::from_secs(60);
18 +
19 +/// A fake siGit Code Cloud: answers `POST /oauth/token` with a token and
20 +/// `GET /api/v1/user` with an email. Returns its base URL.
21 +fn start_fake_cloud() -> String {
22 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake cloud");
23 + let port = listener.local_addr().unwrap().port();
24 + std::thread::spawn(move || {
25 + for stream in listener.incoming() {
26 + let Ok(mut stream) = stream else { continue };
27 + let mut reader = BufReader::new(stream.try_clone().unwrap());
28 + let mut request_line = String::new();
29 + reader.read_line(&mut request_line).ok();
30 + let mut content_length = 0usize;
31 + loop {
32 + let mut line = String::new();
33 + if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
34 + break;
35 + }
36 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
37 + content_length = length.trim().parse().unwrap_or(0);
38 + }
39 + }
40 + let mut body = vec![0u8; content_length];
41 + reader.read_exact(&mut body).ok();
42 + let payload = if request_line.contains("/oauth/token") {
43 + json!({"access_token": "tok_from_browser", "token_type": "Bearer"})
44 + } else {
45 + json!({"email": "dev@sigit.si"})
46 + }
47 + .to_string();
48 + let response = format!(
49 + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
50 + content-length: {}\r\nconnection: close\r\n\r\n{}",
51 + payload.len(),
52 + payload
53 + );
54 + stream.write_all(response.as_bytes()).ok();
55 + }
56 + });
57 + format!("http://127.0.0.1:{port}")
58 +}
59 +
60 +struct AgentUnderTest {
61 + child: Child,
62 + stdin: ChildStdin,
63 + incoming: Receiver<Value>,
64 +}
65 +
66 +fn spawn_agent(cloud: &str, config_dir: &std::path::Path) -> AgentUnderTest {
67 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
68 + .env("SIGIT_API_URL", cloud)
69 + .env("SIGIT_CLOUD_URL", cloud)
70 + .env("SIGIT_CONFIG_DIR", config_dir)
71 + .env("SIGIT_MCP", "off")
72 + // Nothing here talks to a model; any endpoint will do.
73 + .env("OPENAI_BASE_URL", "http://127.0.0.1:9")
74 + .env("OPENAI_API_KEY", "test-key")
75 + .env("SIGIT_MODEL", "scripted-model")
76 + .env_remove("SIGIT_LOCAL_INFERENCE")
77 + .stdin(Stdio::piped())
78 + .stdout(Stdio::piped())
79 + .stderr(Stdio::null())
80 + .spawn()
81 + .expect("spawn sigit in ACP mode");
82 +
83 + let stdout = child.stdout.take().unwrap();
84 + let (message_tx, incoming) = channel();
85 + std::thread::spawn(move || {
86 + for line in BufReader::new(stdout).lines() {
87 + let Ok(line) = line else { break };
88 + if let Ok(message) = serde_json::from_str::<Value>(&line)
89 + && message_tx.send(message).is_err()
90 + {
91 + break;
92 + }
93 + }
94 + });
95 + let stdin = child.stdin.take().unwrap();
96 + AgentUnderTest {
97 + child,
98 + stdin,
99 + incoming,
100 + }
101 +}
102 +
103 +impl AgentUnderTest {
104 + fn send(&mut self, message: Value) {
105 + let mut line = message.to_string();
106 + line.push('\n');
107 + self.stdin.write_all(line.as_bytes()).unwrap();
108 + self.stdin.flush().unwrap();
109 + }
110 +
111 + fn next(&mut self, what: &str, matches: impl Fn(&Value) -> bool) -> Value {
112 + let deadline = Instant::now() + TIMEOUT;
113 + loop {
114 + let remaining = deadline.saturating_duration_since(Instant::now());
115 + match self.incoming.recv_timeout(remaining) {
116 + Ok(message) if matches(&message) => return message,
117 + Ok(_) => continue,
118 + Err(_) => panic!("timed out waiting for {what}"),
119 + }
120 + }
121 + }
122 +
123 + fn response(&mut self, id: u64) -> Value {
124 + self.next(&format!("the response to {id}"), |message| {
125 + message["id"] == id && message.get("method").is_none()
126 + })
127 + }
128 +}
129 +
130 +impl Drop for AgentUnderTest {
131 + fn drop(&mut self) {
132 + let _ = self.child.kill();
133 + let _ = self.child.wait();
134 + }
135 +}
136 +
137 +/// The value of `key` in the authorize URL's query, percent-decoded.
138 +fn query_value(url: &str, key: &str) -> String {
139 + let query = url.split_once('?').expect("query").1;
140 + let raw = query
141 + .split('&')
142 + .find_map(|pair| pair.strip_prefix(&format!("{key}=")))
143 + .unwrap_or_else(|| panic!("{key} missing from {url}"));
144 + let mut decoded = String::new();
145 + let mut bytes = raw.bytes();
146 + while let Some(byte) = bytes.next() {
147 + if byte == b'%' {
148 + let hex: String = [bytes.next().unwrap(), bytes.next().unwrap()]
149 + .iter()
150 + .map(|&b| b as char)
151 + .collect();
152 + decoded.push(u8::from_str_radix(&hex, 16).unwrap() as char);
153 + } else {
154 + decoded.push(byte as char);
155 + }
156 + }
157 + decoded
158 +}
159 +
160 +/// Start `authenticate` from a client that renders URL elicitations, and
161 +/// return the agent, the request id, and the elicitation it was sent.
162 +fn start_sign_in(tag: &str) -> (AgentUnderTest, std::path::PathBuf, Value) {
163 + let cloud = start_fake_cloud();
164 + let scratch =
165 + std::env::temp_dir().join(format!("sigit_acp_elicit_{tag}_{}", std::process::id()));
166 + let config_dir = scratch.join("config");
167 + std::fs::create_dir_all(&config_dir).unwrap();
168 +
169 + let mut agent = spawn_agent(&cloud, &config_dir);
170 + agent.send(json!({
171 + "jsonrpc": "2.0", "id": 1, "method": "initialize",
172 + "params": {
173 + "protocolVersion": 1,
174 + "clientCapabilities": {"elicitation": {"url": {}}},
175 + },
176 + }));
177 + agent.response(1);
178 +
179 + agent.send(json!({
180 + "jsonrpc": "2.0", "id": 2, "method": "authenticate",
181 + "params": {"methodId": "sigit"},
182 + }));
183 + let elicitation = agent.next("the sign-in elicitation", |message| {
184 + message["method"] == "elicitation/create"
185 + });
186 + let params = &elicitation["params"];
187 + assert_eq!(params["mode"], "url", "{params}");
188 + assert_eq!(
189 + params["requestId"], 2,
190 + "scoped to the authenticate request: {params}"
191 + );
192 + assert!(
193 + params["url"]
194 + .as_str()
195 + .unwrap_or_default()
196 + .starts_with(&format!("{cloud}/oauth/authorize?")),
197 + "{params}"
198 + );
199 + assert!(params["elicitationId"].is_string(), "{params}");
200 + (agent, scratch, elicitation)
201 +}
202 +
203 +#[test]
204 +fn sign_in_hands_the_page_to_a_client_that_renders_urls() {
205 + let (mut agent, scratch, elicitation) = start_sign_in("accept");
206 + let params = &elicitation["params"];
207 + let url = params["url"].as_str().unwrap().to_string();
208 +
209 + agent.send(json!({
210 + "jsonrpc": "2.0", "id": elicitation["id"], "result": {"action": "accept"},
211 + }));
212 +
213 + // The browser finishes the authorization and comes back to sigit.
214 + let redirect = query_value(&url, "redirect_uri");
215 + let state = query_value(&url, "state");
216 + let address = redirect
217 + .strip_prefix("http://")
218 + .and_then(|rest| rest.split_once('/'))
219 + .expect("loopback redirect")
220 + .0
221 + .to_string();
222 + let mut browser = TcpStream::connect(&address).expect("reach the loopback listener");
223 + write!(
224 + browser,
225 + "GET /callback?code=abc&state={state} HTTP/1.1\r\nHost: {address}\r\n\r\n"
226 + )
227 + .unwrap();
228 + let mut page = String::new();
229 + browser.read_to_string(&mut page).ok();
230 + assert!(page.contains("Signed in"), "{page}");
231 +
232 + // The client hears that the page is done with, then gets its answer.
233 + let complete = agent.next("elicitation/complete", |message| {
234 + message["method"] == "elicitation/complete"
235 + });
236 + assert_eq!(complete["params"]["elicitationId"], params["elicitationId"]);
237 + let response = agent.response(2);
238 + assert!(response.get("error").is_none(), "{response}");
239 + let saved = std::fs::read_to_string(scratch.join("config/credentials.toml")).unwrap();
240 + assert!(saved.contains("tok_from_browser"), "{saved}");
241 +
242 + drop(agent);
243 + let _ = std::fs::remove_dir_all(&scratch);
244 +}
245 +
246 +#[test]
247 +fn declining_the_sign_in_page_ends_the_sign_in() {
248 + let (mut agent, scratch, elicitation) = start_sign_in("decline");
249 +
250 + agent.send(json!({
251 + "jsonrpc": "2.0", "id": elicitation["id"], "result": {"action": "decline"},
252 + }));
253 +
254 + let response = agent.response(2);
255 + assert!(
256 + response["error"]["message"]
257 + .as_str()
258 + .unwrap_or_default()
259 + .contains("Sign-in did not complete"),
260 + "{response}"
261 + );
262 + assert!(!scratch.join("config/credentials.toml").exists());
263 +
264 + drop(agent);
265 + let _ = std::fs::remove_dir_all(&scratch);
266 +}