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(());