I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.
Restyle the browser sign-in page and answer it after the token exchange
The loopback page is now a centered logo, title and hint on a dark canvas, with inline CSS and an inline logo so it makes no requests. The success page used to be sent as soon as the callback arrived, before the code was redeemed. If the exchange then failed, the browser said "Signed in" while the app reported an error. wait_for_code now hands back the open connection and complete_loopback responds once the exchange has finished, with a separate page for the failure. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 21, 2026 at 23:36 UTC
c5c245c384bcb659f939e69119c0b43e01f4310a
2 files changed
+88
-26
crates/sigit-agent/assets/sign-in-logo.png
Binary files /dev/null and b/crates/sigit-agent/assets/sign-in-logo.png differ
crates/sigit-agent/src/browser_auth.rs
+88
-26
@@ -180,11 +180,27 @@ pub async fn complete_loopback(flow: Flow) -> Result<String, String> {
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
183
+ let (code, mut stream) =
184
+ tokio::task::spawn_blocking(move || wait_for_code(listener, &expected_state))
185
+ .await
186
+ .map_err(|error| format!("sign-in was interrupted: {error}"))??;
187
+
188
+ // The browser is still waiting on its request. Answer it only once the
189
+ // exchange has settled, so the page never reports a sign-in that failed.
190
+ let result = exchange(&code, &verifier, &redirect_uri).await;
191
+ match &result {
192
+ Ok(_) => respond(
193
+ &mut stream,
194
+ "Signed in to siGit Code",
195
+ "You can close this tab.",
196
+ ),
197
+ Err(_) => respond(
198
+ &mut stream,
199
+ "Sign-in did not complete",
200
+ "Return to siGit Code and try again.",
201
+ ),
202
+ }
203
+ result
204
}
205
206
/// Redeem a code the person pasted in. Returns the signed-in email.
@@ -198,8 +214,12 @@ pub async fn complete_paste(flow: &Flow, code: &str) -> Result<String, String> {
214
215
/// Accept connections until one carries our callback. Anything else on the
216
/// 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> {
217
+/// ending the wait. On success the matching connection is returned unanswered,
218
+/// for the caller to respond to once the code has been redeemed.
219
+fn wait_for_code(
220
+ listener: TcpListener,
221
+ expected_state: &str,
222
+) -> Result<(String, std::net::TcpStream), String> {
223
let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT;
224
listener
225
.set_nonblocking(true)
@@ -243,7 +263,7 @@ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String,
263
}
264
}
265
Err(error) => {
246
- respond(&mut stream, "Could not read the sign-in callback.");
266
+ respond(&mut stream, "Nothing to do here", "");
267
log::debug!("could not read browser callback: {error}");
268
continue 'connections;
269
}
@@ -255,7 +275,7 @@ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String,
275
.next()
276
.and_then(|line| line.split_whitespace().nth(1))
277
else {
258
- respond(&mut stream, "Bad request.");
278
+ respond(&mut stream, "Nothing to do here", "");
279
continue;
280
};
281
@@ -273,39 +293,68 @@ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String,
293
}
294
295
if let Some(error) = error {
276
- respond(
277
- &mut stream,
278
- "Sign-in was cancelled. You can close this tab.",
279
- );
296
+ respond(&mut stream, "Sign-in cancelled", "You can close this tab.");
297
return Err(format!("sign-in was declined ({error})"));
298
}
299
300
let Some(code) = code else {
284
- respond(&mut stream, "Nothing to do here.");
301
+ respond(&mut stream, "Nothing to do here", "");
302
continue;
303
};
304
305
// The state proves this callback belongs to the flow we started, not
306
// to a link someone else sent to this browser.
307
if state.as_deref() != Some(expected_state) {
291
- respond(&mut stream, "That sign-in did not come from siGit Code.");
308
+ respond(
309
+ &mut stream,
310
+ "Sign-in didn't match",
311
+ "That sign-in did not come from siGit Code.",
312
+ );
313
return Err("the sign-in response did not match this request".to_string());
314
}
315
295
- respond(
296
- &mut stream,
297
- "Signed in to siGit Code. You can close this tab.",
298
- );
299
- return Ok(code);
316
+ return Ok((code, stream));
317
}
318
}
319
303
-fn respond(stream: &mut std::net::TcpStream, message: &str) {
304
- let body = format!(
305
- "<!doctype html><meta charset=\"utf-8\"><title>siGit Code</title>\
306
- <body style=\"font:16px system-ui;display:grid;place-items:center;height:90vh\">\
307
- <p>{message}</p></body>"
308
- );
320
+/// The 64x64 app icon, inlined into the page so it needs no second request.
321
+const LOGO_PNG: &[u8] = include_bytes!("../assets/sign-in-logo.png");
322
+
323
+/// Renders the page the browser lands on. Self-contained: inline CSS and an
324
+/// inline logo, because the page is served from the loopback address and must
325
+/// not fetch anything. Title and hint are constants, so nothing is escaped.
326
+fn page(title: &str, hint: &str) -> String {
327
+ let logo = base64::engine::general_purpose::STANDARD.encode(LOGO_PNG);
328
+ let hint = if hint.is_empty() {
329
+ String::new()
330
+ } else {
331
+ format!("<p>{hint}</p>")
332
+ };
333
+ format!(
334
+ "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
335
+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
336
+ <meta name=\"color-scheme\" content=\"dark\"><title>{title}</title><style>\
337
+ :root{{color-scheme:dark}}\
338
+ html,body{{margin:0;height:100%}}\
339
+ body{{display:flex;align-items:center;justify-content:center;background:#15171a;\
340
+ font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif}}\
341
+ main{{display:flex;flex-direction:column;align-items:center;text-align:center;\
342
+ padding:0 24px;transform:translateY(-6%);animation:fade-in 240ms ease-out}}\
343
+ img{{width:40px;height:40px;border-radius:9px;opacity:.8;\
344
+ box-shadow:0 0 0 1px rgb(255 255 255/.06)}}\
345
+ h1{{margin:16px 0 0;font-size:15px;line-height:20px;font-weight:500;\
346
+ letter-spacing:-.01em;color:#f3f4f6}}\
347
+ p{{margin:4px 0 0;font-size:13px;line-height:18px;color:#6b7280}}\
348
+ @keyframes fade-in{{from{{opacity:0}}to{{opacity:1}}}}\
349
+ @media(prefers-reduced-motion:reduce){{main{{animation:none}}}}\
350
+ </style></head><body><main>\
351
+ <img src=\"data:image/png;base64,{logo}\" alt=\"\" width=\"40\" height=\"40\">\
352
+ <h1>{title}</h1>{hint}</main></body></html>"
353
+ )
354
+}
355
+
356
+fn respond(stream: &mut std::net::TcpStream, title: &str, hint: &str) {
357
+ let body = page(title, hint);
358
let response = format!(
359
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
360
body.len(),
@@ -464,6 +513,19 @@ fn percent_decode(value: &str) -> String {
513
mod tests {
514
use super::*;
515
516
+ #[test]
517
+ fn page_carries_the_title_hint_and_an_inline_logo() {
518
+ let body = page("Signed in to siGit Code", "You can close this tab.");
519
+ assert!(body.contains("<h1>Signed in to siGit Code</h1>"));
520
+ assert!(body.contains("<p>You can close this tab.</p>"));
521
+ assert!(body.contains("data:image/png;base64,"));
522
+ }
523
+
524
+ #[test]
525
+ fn page_omits_the_hint_when_empty() {
526
+ assert!(!page("Nothing to do here", "").contains("<p>"));
527
+ }
528
+
529
#[test]
530
fn code_challenge_matches_rfc_7636_example() {
531
// RFC 7636 Appendix B.