Implement Zed signin button

paydii committed Aug 27, 2026 at 20:16 UTC 693e3258cf531c28801fb81a7d7c10ff1ff5f758
6 files changed +644 -18
.agents/AGENTS.md
+12
@@ -211,6 +211,18 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
211 those read env vars once at init.
212 - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
213 against the account API and stores a session token. Performs no console I/O.
214 +- **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
215 + button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
216 + S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
217 + back: a loopback listener on `127.0.0.1:0` (the registration lists `http://127.0.0.1/callback`
218 + with no port, and RFC 8252 §7.3 matching ignores the port, so one registration covers every
219 + run), or the out-of-band URN, where the server renders the code and the user pastes it. The
220 + resulting token goes into `credentials` like any other, and is served by `/api/v1/user` rather
221 + than the deprecated `/api/v1/me` — OAuth tokens are rejected there. The scopes requested are
222 + `user:read code:agent`; `code:agent` is what reaches `chat/completions` and the official MCP
223 + server. Surfaces: ACP `authenticate` (loopback only — nowhere to show a code), a bare `/login`
224 + in either chat surface, and `sigit login` (which adds the paste fallback, plus `--paste` to
225 + force it and `--password` for the old email/password prompt).
226 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
227 - **`src/models.rs`** — model-picker types shared across platforms.
228
Cargo.lock
+3
@@ -5611,17 +5611,20 @@ dependencies = [
5611 "agent-client-protocol",
5612 "anyhow",
5613 "async-trait",
5614 + "base64 0.22.1",
5615 "crossterm 0.29.0",
5616 "futures",
5617 "libc",
5618 "log",
5619 "onde",
5620 + "rand 0.9.4",
5621 "ratatui",
5622 "regex",
5623 "reqwest 0.12.28",
5624 "rpassword",
5625 "serde",
5626 "serde_json",
5627 + "sha2",
5628 "tokio",
5629 "tokio-util",
5630 "toml 0.8.23",
Cargo.toml
+5
@@ -54,6 +54,11 @@ regex = "1"
54 reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
55 uuid = { version = "1", features = ["v4"] }
56 rpassword = "7"
57 +# Browser sign-in: PKCE needs a SHA-256 and base64url, the verifier and state
58 +# need a CSPRNG. All three already came in through the dependency graph.
59 +base64 = "0.22"
60 +rand = "0.9"
61 +sha2 = "0.10"
62
63 # Release binaries ship to five registries, and the NuGet package bundles all
64 # six targets in a single archive, so binary size is a distribution constraint
src/browser_auth.rs new
+497
@@ -0,0 +1,497 @@
1 +//! Browser sign-in for siGit Code Cloud.
2 +//!
3 +//! The alternative to typing a password into a chat box. sigit is a public
4 +//! OAuth client: it opens the browser at sigit.si, the person signs in and
5 +//! approves there, and the authorization code comes back here. PKCE (RFC 7636)
6 +//! is what proves the code belongs to this process, since a shipped CLI can
7 +//! hold no client secret.
8 +//!
9 +//! The code comes home one of two ways:
10 +//!
11 +//! - **Loopback** (RFC 8252 §7.3). We bind `127.0.0.1:0`, let the OS pick the
12 +//! port, and hand that URL to the server as the redirect URI. The server
13 +//! registration lists `http://127.0.0.1/callback` with no port, which the
14 +//! provider matches port-agnostically, so no per-run registration is needed.
15 +//! Used by the editor's "Sign in" button, where there is nowhere to type.
16 +//! - **Paste** (the out-of-band URN). No listener, no open port. The browser
17 +//! lands on a page showing the code and the person carries it back by hand.
18 +//! This is the path that survives ssh, a remote editor, or a locked-down box
19 +//! where binding a port fails.
20 +//!
21 +//! The token that comes out is stored by [`crate::credentials`] exactly like
22 +//! the one `/login` produces, so everything downstream is unchanged.
23 +
24 +use std::io::{Read, Write};
25 +use std::net::TcpListener;
26 +use std::time::Duration;
27 +
28 +use base64::Engine as _;
29 +use sha2::{Digest, Sha256};
30 +
31 +use crate::credentials::{self, Credentials};
32 +
33 +/// Client id of the siGit Code registration on sigit.si. Public clients have
34 +/// no secret and their id is not one either, so it ships as a constant rather
35 +/// than being configured per install. Pinned server-side by
36 +/// `rake oauth:register_cli`.
37 +const OAUTH_CLIENT_ID: &str = "sigit-code-cli";
38 +
39 +/// `user:read` for whoami, `code:agent` for inference and the MCP tools.
40 +const OAUTH_SCOPE: &str = "user:read code:agent";
41 +
42 +/// Non-standard redirect URI that asks the server to display the code instead
43 +/// of redirecting anywhere.
44 +const OOB_REDIRECT_URI: &str = "urn:ietf:wg:oauth:2.0:oob";
45 +
46 +/// How long to hold the loopback listener open waiting for the browser. The
47 +/// authorization code itself expires server-side in 10 minutes; this is the
48 +/// shorter local patience for someone who walked away.
49 +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
50 +
51 +/// Default account host. Override with `SIGIT_API_URL` (dev: `http://localhost:8088`).
52 +const DEFAULT_API_URL: &str = "https://sigit.si";
53 +
54 +fn api_base() -> String {
55 + std::env::var("SIGIT_API_URL")
56 + .unwrap_or_else(|_| DEFAULT_API_URL.to_string())
57 + .trim_end_matches('/')
58 + .to_string()
59 +}
60 +
61 +/// An authorization in flight: the URL to send the browser to, and the secrets
62 +/// needed to redeem whatever code comes back.
63 +pub struct Flow {
64 + authorize_url: String,
65 + redirect_uri: String,
66 + verifier: String,
67 + state: String,
68 + /// Present only for the loopback variant.
69 + listener: Option<TcpListener>,
70 +}
71 +
72 +impl Flow {
73 + /// The URL the person needs to visit. Worth showing even when we opened it
74 + /// ourselves: the browser we launch is not always the one they're signed
75 + /// in to.
76 + pub fn authorize_url(&self) -> &str {
77 + &self.authorize_url
78 + }
79 +
80 + /// True when a code will arrive on its own, false when it has to be pasted.
81 + pub fn is_loopback(&self) -> bool {
82 + self.listener.is_some()
83 + }
84 +}
85 +
86 +/// Start a loopback authorization, falling back to the paste variant when no
87 +/// local port can be bound.
88 +pub fn begin() -> Flow {
89 + match TcpListener::bind(("127.0.0.1", 0)).and_then(|listener| {
90 + let port = listener.local_addr()?.port();
91 + listener.set_nonblocking(false)?;
92 + Ok((listener, port))
93 + }) {
94 + Ok((listener, port)) => {
95 + let redirect_uri = format!("http://127.0.0.1:{port}/callback");
96 + build_flow(redirect_uri, Some(listener))
97 + }
98 + Err(error) => {
99 + log::warn!("no loopback port for browser sign-in ({error}); using the paste flow");
100 + build_flow(OOB_REDIRECT_URI.to_string(), None)
101 + }
102 + }
103 +}
104 +
105 +/// Start a paste authorization outright, without touching the network stack.
106 +pub fn begin_paste() -> Flow {
107 + build_flow(OOB_REDIRECT_URI.to_string(), None)
108 +}
109 +
110 +fn build_flow(redirect_uri: String, listener: Option<TcpListener>) -> Flow {
111 + let verifier = random_token();
112 + let state = random_token();
113 + let challenge = code_challenge(&verifier);
114 +
115 + let authorize_url = format!(
116 + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
117 + api_base(),
118 + percent_encode(OAUTH_CLIENT_ID),
119 + percent_encode(&redirect_uri),
120 + percent_encode(OAUTH_SCOPE),
121 + percent_encode(&state),
122 + percent_encode(&challenge),
123 + );
124 +
125 + Flow {
126 + authorize_url,
127 + redirect_uri,
128 + verifier,
129 + state,
130 + listener,
131 + }
132 +}
133 +
134 +/// Hand the authorize URL to the platform's browser. Best-effort: a headless
135 +/// box has nothing to open, which is not a reason to abandon the flow, since
136 +/// the URL can still be carried to a browser elsewhere.
137 +pub fn open_browser(url: &str) -> bool {
138 + #[cfg(target_os = "macos")]
139 + let mut command = {
140 + let mut c = std::process::Command::new("open");
141 + c.arg(url);
142 + c
143 + };
144 + #[cfg(target_os = "windows")]
145 + let mut command = {
146 + // `start` is a cmd builtin, and its first quoted argument is taken as
147 + // the window title, hence the empty one.
148 + let mut c = std::process::Command::new("cmd");
149 + c.args(["/C", "start", "", url]);
150 + c
151 + };
152 + #[cfg(all(unix, not(target_os = "macos")))]
153 + let mut command = {
154 + let mut c = std::process::Command::new("xdg-open");
155 + c.arg(url);
156 + c
157 + };
158 +
159 + command
160 + .stdout(std::process::Stdio::null())
161 + .stderr(std::process::Stdio::null())
162 + .status()
163 + .map(|status| status.success())
164 + .unwrap_or(false)
165 +}
166 +
167 +/// Block on the loopback listener until the browser redirects back, then
168 +/// redeem the code and store the session. Returns the signed-in email.
169 +///
170 +/// Runs the socket work on a blocking thread: the wait is minutes long and
171 +/// must not sit on an async worker.
172 +pub async fn complete_loopback(flow: Flow) -> Result<String, String> {
173 + let Flow {
174 + redirect_uri,
175 + verifier,
176 + state,
177 + listener,
178 + ..
179 + } = flow;
180 + let listener = listener.ok_or_else(|| "this sign-in has no local listener".to_string())?;
181 +
182 + let expected_state = state.clone();
183 + let code = tokio::task::spawn_blocking(move || wait_for_code(listener, &expected_state))
184 + .await
185 + .map_err(|error| format!("sign-in was interrupted: {error}"))??;
186 +
187 + exchange(&code, &verifier, &redirect_uri).await
188 +}
189 +
190 +/// Redeem a code the person pasted in. Returns the signed-in email.
191 +pub async fn complete_paste(flow: &Flow, code: &str) -> Result<String, String> {
192 + let code = code.trim();
193 + if code.is_empty() {
194 + return Err("no code was entered".to_string());
195 + }
196 + exchange(code, &flow.verifier, &flow.redirect_uri).await
197 +}
198 +
199 +/// Accept connections until one carries our callback. Anything else on the
200 +/// port (a browser probe, a stray scan) is answered and ignored rather than
201 +/// ending the wait.
202 +fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String, String> {
203 + let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT;
204 + listener
205 + .set_nonblocking(true)
206 + .map_err(|error| format!("could not watch for the browser: {error}"))?;
207 +
208 + loop {
209 + if std::time::Instant::now() >= deadline {
210 + return Err("timed out waiting for the browser".to_string());
211 + }
212 +
213 + let mut stream = match listener.accept() {
214 + Ok((stream, _)) => stream,
215 + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
216 + std::thread::sleep(Duration::from_millis(100));
217 + continue;
218 + }
219 + Err(error) => return Err(format!("could not accept the browser: {error}")),
220 + };
221 + stream.set_nonblocking(false).ok();
222 + stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
223 +
224 + // The request line is all we need, and it arrives in the first packet.
225 + let mut buffer = [0_u8; 4096];
226 + let read = stream.read(&mut buffer).unwrap_or(0);
227 + let request = String::from_utf8_lossy(&buffer[..read]);
228 + let Some(target) = request
229 + .lines()
230 + .next()
231 + .and_then(|line| line.split_whitespace().nth(1))
232 + else {
233 + respond(&mut stream, "Bad request.");
234 + continue;
235 + };
236 +
237 + let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
238 + let mut code = None;
239 + let mut state = None;
240 + let mut error = None;
241 + for (key, value) in parse_query(query) {
242 + match key.as_str() {
243 + "code" => code = Some(value),
244 + "state" => state = Some(value),
245 + "error" => error = Some(value),
246 + _ => {}
247 + }
248 + }
249 +
250 + if let Some(error) = error {
251 + respond(
252 + &mut stream,
253 + "Sign-in was cancelled. You can close this tab.",
254 + );
255 + return Err(format!("sign-in was declined ({error})"));
256 + }
257 +
258 + let Some(code) = code else {
259 + respond(&mut stream, "Nothing to do here.");
260 + continue;
261 + };
262 +
263 + // The state proves this callback belongs to the flow we started, not
264 + // to a link someone else sent to this browser.
265 + if state.as_deref() != Some(expected_state) {
266 + respond(&mut stream, "That sign-in did not come from siGit Code.");
267 + return Err("the sign-in response did not match this request".to_string());
268 + }
269 +
270 + respond(
271 + &mut stream,
272 + "Signed in to siGit Code. You can close this tab.",
273 + );
274 + return Ok(code);
275 + }
276 +}
277 +
278 +fn respond(stream: &mut std::net::TcpStream, message: &str) {
279 + let body = format!(
280 + "<!doctype html><meta charset=\"utf-8\"><title>siGit Code</title>\
281 + <body style=\"font:16px system-ui;display:grid;place-items:center;height:90vh\">\
282 + <p>{message}</p></body>"
283 + );
284 + let response = format!(
285 + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
286 + body.len(),
287 + body
288 + );
289 + let _ = stream.write_all(response.as_bytes());
290 + let _ = stream.flush();
291 +}
292 +
293 +/// Trade the authorization code for an access token and store it.
294 +async fn exchange(code: &str, verifier: &str, redirect_uri: &str) -> Result<String, String> {
295 + let url = format!("{}/oauth/token", api_base());
296 + let response = reqwest::Client::new()
297 + .post(&url)
298 + .form(&[
299 + ("grant_type", "authorization_code"),
300 + ("client_id", OAUTH_CLIENT_ID),
301 + ("redirect_uri", redirect_uri),
302 + ("code", code),
303 + ("code_verifier", verifier),
304 + ])
305 + .send()
306 + .await
307 + .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
308 +
309 + let status = response.status();
310 + let body: serde_json::Value = response
311 + .json()
312 + .await
313 + .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?;
314 +
315 + if !status.is_success() {
316 + // OAuth error bodies are `{"error":…,"error_description":…}`.
317 + let detail = body
318 + .get("error_description")
319 + .or_else(|| body.get("error"))
320 + .and_then(|value| value.as_str())
321 + .unwrap_or("the code could not be exchanged");
322 + return Err(format!("sign-in failed: {detail}"));
323 + }
324 +
325 + let token = body
326 + .get("access_token")
327 + .and_then(|value| value.as_str())
328 + .filter(|token| !token.trim().is_empty())
329 + .ok_or_else(|| "siGit Code Cloud returned no access token".to_string())?;
330 +
331 + // Store before the profile lookup: the token is the thing worth keeping,
332 + // and the email is only there to label it.
333 + credentials::store(&Credentials {
334 + access_token: token.to_string(),
335 + email: None,
336 + })?;
337 +
338 + let email = fetch_email(token).await;
339 + if email.is_some() {
340 + credentials::store(&Credentials {
341 + access_token: token.to_string(),
342 + email: email.clone(),
343 + })?;
344 + }
345 +
346 + Ok(email.unwrap_or_else(|| "(unknown)".to_string()))
347 +}
348 +
349 +/// Read the account email for display. OAuth tokens are served by
350 +/// `/api/v1/user`, not the deprecated `/api/v1/me`.
351 +async fn fetch_email(token: &str) -> Option<String> {
352 + let url = format!("{}/api/v1/user", api_base());
353 + let response = reqwest::Client::new()
354 + .get(&url)
355 + .bearer_auth(token)
356 + .send()
357 + .await
358 + .ok()?;
359 + if !response.status().is_success() {
360 + return None;
361 + }
362 + response
363 + .json::<serde_json::Value>()
364 + .await
365 + .ok()?
366 + .get("email")?
367 + .as_str()
368 + .map(str::to_string)
369 +}
370 +
371 +/// 32 random bytes, base64url without padding: a PKCE verifier and a state
372 +/// value both want an unguessable ASCII string of exactly this shape.
373 +fn random_token() -> String {
374 + let mut bytes = [0_u8; 32];
375 + rand::fill(&mut bytes);
376 + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
377 +}
378 +
379 +/// S256: base64url(sha256(verifier)), unpadded.
380 +fn code_challenge(verifier: &str) -> String {
381 + let digest = Sha256::digest(verifier.as_bytes());
382 + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
383 +}
384 +
385 +/// Percent-encode a query-string value. Everything outside the unreserved set
386 +/// is escaped, which is stricter than necessary and never wrong.
387 +fn percent_encode(value: &str) -> String {
388 + let mut out = String::with_capacity(value.len());
389 + for byte in value.bytes() {
390 + match byte {
391 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
392 + out.push(byte as char)
393 + }
394 + _ => out.push_str(&format!("%{byte:02X}")),
395 + }
396 + }
397 + out
398 +}
399 +
400 +/// Split a query string into decoded key/value pairs.
401 +fn parse_query(query: &str) -> Vec<(String, String)> {
402 + query
403 + .split('&')
404 + .filter(|pair| !pair.is_empty())
405 + .map(|pair| {
406 + let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
407 + (percent_decode(key), percent_decode(value))
408 + })
409 + .collect()
410 +}
411 +
412 +fn percent_decode(value: &str) -> String {
413 + let bytes = value.as_bytes();
414 + let mut out = Vec::with_capacity(bytes.len());
415 + let mut index = 0;
416 + while index < bytes.len() {
417 + match bytes[index] {
418 + b'%' if index + 2 < bytes.len() => {
419 + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or("");
420 + match u8::from_str_radix(hex, 16) {
421 + Ok(byte) => {
422 + out.push(byte);
423 + index += 3;
424 + }
425 + Err(_) => {
426 + out.push(bytes[index]);
427 + index += 1;
428 + }
429 + }
430 + }
431 + b'+' => {
432 + out.push(b' ');
433 + index += 1;
434 + }
435 + byte => {
436 + out.push(byte);
437 + index += 1;
438 + }
439 + }
440 + }
441 + String::from_utf8_lossy(&out).into_owned()
442 +}
443 +
444 +#[cfg(test)]
445 +mod tests {
446 + use super::*;
447 +
448 + #[test]
449 + fn code_challenge_matches_rfc_7636_example() {
450 + // RFC 7636 Appendix B.
451 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
452 + assert_eq!(
453 + code_challenge(verifier),
454 + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
455 + );
456 + }
457 +
458 + #[test]
459 + fn authorize_url_carries_pkce_and_the_bound_port() {
460 + let flow = begin();
461 + assert!(flow.is_loopback(), "a loopback port should be available");
462 + assert!(flow.authorize_url().contains("code_challenge_method=S256"));
463 + assert!(flow.authorize_url().contains("response_type=code"));
464 + // The redirect URI is encoded, so look for the escaped form.
465 + assert!(
466 + flow.authorize_url().contains("http%3A%2F%2F127.0.0.1%3A"),
467 + "authorize url should carry the loopback redirect: {}",
468 + flow.authorize_url()
469 + );
470 + }
471 +
472 + #[test]
473 + fn paste_flow_asks_the_server_to_display_the_code() {
474 + let flow = begin_paste();
475 + assert!(!flow.is_loopback());
476 + assert!(
477 + flow.authorize_url()
478 + .contains("urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob")
479 + );
480 + }
481 +
482 + #[test]
483 + fn verifier_and_state_differ_between_flows() {
484 + let first = begin_paste();
485 + let second = begin_paste();
486 + assert_ne!(first.verifier, second.verifier);
487 + assert_ne!(first.state, second.state);
488 + assert_eq!(first.verifier.len(), 43, "32 bytes as unpadded base64url");
489 + }
490 +
491 + #[test]
492 + fn query_parsing_decodes_escapes() {
493 + let pairs = parse_query("code=abc%2Fdef&state=x+y");
494 + assert_eq!(pairs[0], ("code".to_string(), "abc/def".to_string()));
495 + assert_eq!(pairs[1], ("state".to_string(), "x y".to_string()));
496 + }
497 +}
src/chat.rs
+12 -3
@@ -1946,7 +1946,8 @@ mod tui {
1946 Mcp,
1947 /// explicitly load the selected (or default) on-device model
1948 Load,
1949 - /// `/login <email> <password>` — the raw argument, parsed when executed.
1949 + /// `/login` (browser) or `/login <email> <password>` — the raw argument,
1950 + /// parsed when executed.
1951 Login(Option<String>),
1952 Logout,
1953 Whoami,
@@ -2729,7 +2730,8 @@ mod tui {
2730 /commands — list user-defined commands (.sigit/commands/*.md)\n\
2731 /mcp — list MCP servers and their tools\n\
2732 /load — load the selected on-device model\n\
2732 - /login E P — sign in to siGit Code Cloud\n\
2733 + /login — sign in via your browser\n\
2734 + /login E P — sign in with an email and password\n\
2735 /logout — sign out\n\
2736 /whoami — show the signed-in account\n\
2737 /plan [on|off] — plan mode: research only, no edits or commands\n\
@@ -2953,7 +2955,14 @@ mod tui {
2955 Err(error) => format!("Login failed: {error}"),
2956 }
2957 }
2956 - None => "usage: /login <email> <password>".to_string(),
2958 + // A bare `/login` opens the browser instead of asking for
2959 + // a password in the chat box.
2960 + None => {
2961 + app.messages.push(ChatMessage::system(
2962 + "Opening your browser to sign in to siGit Code Cloud...",
2963 + ));
2964 + crate::browser_login_message().await
2965 + }
2966 };
2967 app.messages.push(ChatMessage::system(message));
2968 }
src/main.rs
+115 -15
@@ -30,6 +30,7 @@
30
31 mod account;
32 mod backend;
33 +mod browser_auth;
34 mod chat;
35 mod commands;
36 mod credentials;
@@ -830,7 +831,11 @@ impl SiGitAgent {
831 ),
832 AvailableCommand::new("mcp", "List MCP servers and their tools"),
833 AvailableCommand::new("load", "Load the selected on-device model"),
833 - with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"),
834 + with_hint(
835 + "login",
836 + "Sign in to siGit Code Cloud (opens your browser)",
837 + "[email] [password]",
838 + ),
839 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
840 AvailableCommand::new("whoami", "Show the signed-in account"),
841 AvailableCommand::new("reload", "Re-sync sign-in and model state"),
@@ -980,12 +985,11 @@ impl SiGitAgent {
985 // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
986 // like Zed advertise terminal-auth capability but don't actually spawn the
987 // login terminal for *custom* ACP agents, so the button is a silent no-op.
983 - // With an Agent method, clicking calls `authenticate`, which returns either
984 - // confirmation (already signed in via `/login`) or a message telling the
985 - // user to run `/login <email> <password>` — so the button does something.
988 + // With an Agent method, clicking calls `authenticate`, which opens the
989 + // browser and finishes the sign-in itself.
990 let auth_methods = vec![AuthMethod::Agent(
991 AuthMethodAgent::new("sigit", "Sign in to siGit Code")
988 - .description("Sign in with `/login <email> <password>` in the message box."),
992 + .description("Opens sigit.si in your browser to authorize this device."),
993 )];
994
995 Ok(InitializeResponse::new(ProtocolVersion::V1)
@@ -1010,20 +1014,42 @@ impl SiGitAgent {
1014 ) -> agent_client_protocol::Result<AuthenticateResponse> {
1015 log::info!("authenticate: method={}", req.method_id.0);
1016
1013 - // Confirm the stored token works. The button can't collect a password,
1014 - // so an unsigned-in user is pointed at the `/login` slash command; a user
1015 - // already signed in via `/login` gets the gate cleared.
1016 - match account::verify_session().await {
1017 + // An existing session clears the gate without sending anyone to a
1018 + // browser they didn't ask for.
1019 + if let Ok(email) = account::verify_session().await {
1020 + log::info!("authenticate: verified existing session for {email}");
1021 + return Ok(AuthenticateResponse::default());
1022 + }
1023 +
1024 + // Otherwise run the browser flow. There is no way to collect a password
1025 + // here (the method has no UI of its own) and no way to show a code to
1026 + // paste, so this is the loopback variant or nothing: the editor gets an
1027 + // error naming the terminal command that does work without a listener.
1028 + let flow = browser_auth::begin();
1029 + if !flow.is_loopback() {
1030 + return Err(agent_client_protocol::Error::new(
1031 + -32000,
1032 + "Could not open a local port to receive the sign-in. \
1033 + Run `sigit login` in a terminal instead."
1034 + .to_string(),
1035 + ));
1036 + }
1037 +
1038 + log::info!("authenticate: opening browser at {}", flow.authorize_url());
1039 + if !browser_auth::open_browser(flow.authorize_url()) {
1040 + log::warn!("authenticate: no browser could be launched");
1041 + }
1042 +
1043 + match browser_auth::complete_loopback(flow).await {
1044 Ok(email) => {
1018 - log::info!("authenticate: verified session for {email}");
1045 + log::info!("authenticate: signed in as {email}");
1046 Ok(AuthenticateResponse::default())
1047 }
1048 Err(reason) => Err(agent_client_protocol::Error::new(
1049 -32000,
1050 format!(
1024 - "Not signed in to siGit Code Cloud ({reason}). \
1025 - Sign in with `/login <email> <password>` in the message box, \
1026 - or create an account at https://sigit.si."
1051 + "Sign-in did not complete ({reason}). \
1052 + Try again, or create an account at https://sigit.si."
1053 ),
1054 )),
1055 }
@@ -2789,7 +2815,9 @@ async fn exec_slash_acp(
2815 ),
2816 Err(error) => format!("Login failed: {error}"),
2817 },
2792 - None => "usage: /login <email> <password>".to_string(),
2818 + // A bare `/login` takes the browser route, same as the editor's
2819 + // sign-in button.
2820 + None => browser_login_message().await,
2821 };
2822 agent.send_assistant_message(cx, session_id, message).ok();
2823 }
@@ -3310,6 +3338,70 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3338 Ok(())
3339 }
3340
3341 +/// Run the browser sign-in from a chat surface and describe how it went.
3342 +///
3343 +/// Both the TUI and an ACP session reach this from a bare `/login`. Neither can
3344 +/// prompt for a pasted code mid-command, so a box with no bindable port is told
3345 +/// to use `sigit login`, which can.
3346 +pub(crate) async fn browser_login_message() -> String {
3347 + let flow = browser_auth::begin();
3348 + if !flow.is_loopback() {
3349 + return "Could not open a local port to receive the sign-in. \
3350 + Run `sigit login` in a terminal instead."
3351 + .to_string();
3352 + }
3353 +
3354 + browser_auth::open_browser(flow.authorize_url());
3355 + let url = flow.authorize_url().to_string();
3356 +
3357 + match browser_auth::complete_loopback(flow).await {
3358 + Ok(email) => format!("Signed in as {email}. Pick a siGit Code Cloud tier in /models."),
3359 + Err(error) => format!("Login failed: {error}\nYou can also open {url} by hand."),
3360 + }
3361 +}
3362 +
3363 +/// `sigit login` from a shell: open the browser and wait for the code.
3364 +///
3365 +/// Unlike the ACP button this has a terminal to fall back on, so when no local
3366 +/// port can be bound the flow switches to the paste variant and reads the code
3367 +/// from stdin. That is the path an ssh session takes.
3368 +async fn terminal_browser_login() -> Result<String, String> {
3369 + use std::io::{Write, stdin, stdout};
3370 +
3371 + // `--paste` forces the code-in-hand variant. Binding a port succeeds on a
3372 + // remote box, but nothing there can reach it if the browser is running on
3373 + // the laptop in front of you, so the choice can't be made by probing.
3374 + let paste = std::env::args().any(|arg| arg == "--paste");
3375 + let flow = if paste {
3376 + browser_auth::begin_paste()
3377 + } else {
3378 + browser_auth::begin()
3379 + };
3380 + let opened = browser_auth::open_browser(flow.authorize_url());
3381 +
3382 + if flow.is_loopback() {
3383 + if opened {
3384 + println!("Opened your browser to finish signing in.");
3385 + } else {
3386 + println!("Open this URL to finish signing in:");
3387 + }
3388 + println!(" {}", flow.authorize_url());
3389 + println!("Waiting...");
3390 + return browser_auth::complete_loopback(flow).await;
3391 + }
3392 +
3393 + println!("Open this URL to finish signing in:");
3394 + println!(" {}", flow.authorize_url());
3395 + print!("Then paste the code shown there: ");
3396 + stdout().flush().ok();
3397 + let mut code = String::new();
3398 + stdin()
3399 + .read_line(&mut code)
3400 + .map_err(|error| format!("could not read the code: {error}"))?;
3401 +
3402 + browser_auth::complete_paste(&flow, &code).await
3403 +}
3404 +
3405 // ── Entry point ──────────────────────────────────────────────────────────────
3406
3407 #[tokio::main]
@@ -3321,7 +3413,15 @@ async fn main() -> anyhow::Result<()> {
3413 match verb.as_str() {
3414 "login" => {
3415 init_logging(true);
3324 - match account::interactive_login().await {
3416 + // `--password` keeps the old email/password prompt for anyone
3417 + // who wants it (scripts, a box with no browser at all).
3418 + let by_password = std::env::args().any(|arg| arg == "--password");
3419 + let result = if by_password {
3420 + account::interactive_login().await
3421 + } else {
3422 + terminal_browser_login().await
3423 + };
3424 + match result {
3425 Ok(email) => {
3426 println!("Signed in to siGit Code Cloud as {email}.");
3427 return Ok(());