{progress}
diff --git a/desktop/src/styles.css b/desktop/src/styles.css
index 8f19393b9..ed57eb1c7 100644
--- a/desktop/src/styles.css
+++ b/desktop/src/styles.css
@@ -400,6 +400,12 @@ details > summary:hover {
margin-top: 0.9rem;
}
+.provider-sign-in-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
.segmented {
display: inline-flex;
background: var(--sunk);
From 2bf5b24cdaffc5d16f770c67c3fde78c9177aa22 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Wed, 16 Sep 2026 07:40:14 -0700
Subject: [PATCH 2/5] fix(desktop): keep Windows Podman OAuth callback on IPv4
Fixes FOR-308
---
desktop/src-tauri/src/plan.rs | 54 ++++++++++++++++++++++++++++++-----
1 file changed, 47 insertions(+), 7 deletions(-)
diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs
index 2fef5cf60..63db19ff3 100644
--- a/desktop/src-tauri/src/plan.rs
+++ b/desktop/src-tauri/src/plan.rs
@@ -451,6 +451,27 @@ const CHATGPT_STORE: &str = "/root/.langchain/chatgpt-auth.json";
const CHATGPT_LOOPBACK: u16 = 1455;
const CHATGPT_RELAY: u16 = 1456;
+fn publish_chatgpt_callback(
+ command: &mut std::process::Command,
+ engine: crate::engine::Engine,
+ windows: bool,
+) {
+ // Windows Podman's IPv6 forward accepts TCP but closes the callback without an HTTP
+ // response. That prevents localhost clients from trying the working IPv4 address:
+ // Happy Eyeballs stops at the first successful TCP handshake (RFC 8305, sections 5/9.2).
+ // Publish only IPv4 there so localhost falls back after IPv6 connection refusal.
+ // Keep the registered localhost redirect URI and the other runtimes' bindings unchanged.
+ let hosts: &[&str] = if windows && engine == crate::engine::Engine::Podman {
+ &["127.0.0.1"]
+ } else {
+ &["127.0.0.1", "[::1]"]
+ };
+ for host in hosts {
+ command.arg("-p");
+ command.arg(format!("{host}:{CHATGPT_LOOPBACK}:{CHATGPT_RELAY}"));
+ }
+}
+
/**
The ChatGPT sign-in, as a program handed to the harness image.
@@ -567,14 +588,10 @@ impl SigningInToChatGpt {
* Published on loopback only, and on the number the vendor's login advertises.
*
* The container's relay listens on `CHATGPT_RELAY` and forwards to the login's own
- * loopback bind; the browser is sent to `CHATGPT_LOOPBACK` on this machine. Both families
- * are published because a browser resolving the registered `localhost` may pick either, and
- * which one it picks is not ours to decide.
+ * loopback bind; the browser is sent to `CHATGPT_LOOPBACK` on this machine.
+ * publish_chatgpt_callback handles the Windows Podman IPv6 forwarding limitation.
*/
- for host in ["127.0.0.1", "[::1]"] {
- command.arg("-p");
- command.arg(format!("{host}:{CHATGPT_LOOPBACK}:{CHATGPT_RELAY}"));
- }
+ publish_chatgpt_callback(&mut command, engine.engine, cfg!(windows));
command.arg(image);
command.arg("python");
command.arg("-u");
@@ -721,6 +738,29 @@ pub fn openai_url_in(output: &str) -> Option {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::engine::Engine;
+
+ #[test]
+ fn chatgpt_callback_uses_ipv4_only_for_windows_podman() {
+ for (engine, windows, hosts) in [
+ (Engine::Podman, true, vec!["127.0.0.1"]),
+ (Engine::Podman, false, vec!["127.0.0.1", "[::1]"]),
+ (Engine::Docker, true, vec!["127.0.0.1", "[::1]"]),
+ (Engine::Docker, false, vec!["127.0.0.1", "[::1]"]),
+ ] {
+ let mut command = crate::quiet::command(engine.binary());
+ publish_chatgpt_callback(&mut command, engine, windows);
+ let args: Vec<_> = command
+ .get_args()
+ .map(|arg| arg.to_str().unwrap())
+ .collect();
+ let expected: Vec = hosts
+ .into_iter()
+ .flat_map(|host| ["-p".into(), format!("{host}:1455:1456")])
+ .collect();
+ assert_eq!(args, expected, "{engine:?}, Windows={windows}");
+ }
+ }
/// Fixtures are composed from the prefix rather than written out, so no credential-shaped
/// literal sits in this repository for a scanner to find or a person to copy.
From 5757600921e5cfaa35c4d8913b43e65c15f0b3ab Mon Sep 17 00:00:00 2001
From: David McKay
Date: Wed, 16 Sep 2026 07:58:33 -0700
Subject: [PATCH 3/5] fix(desktop): keep Windows Claude sign-in terminal alive
Answer ConPTY's initial cursor query and retain its master through the sign-in exchange. Cover startup, child lifetime, and cleanup with a bounded native Windows regression.
Fixes FOR-308
---
desktop/src-tauri/src/plan.rs | 76 +++++++++++++++++++++++++++++++++++
1 file changed, 76 insertions(+)
diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs
index 63db19ff3..c85ba4248 100644
--- a/desktop/src-tauri/src/plan.rs
+++ b/desktop/src-tauri/src/plan.rs
@@ -202,6 +202,12 @@ pub struct SigningIn {
child: Box,
writer: Box,
output: std::sync::Arc>,
+ // ConPTY's pipe clones do not own its console. Dropping the last master closes the
+ // console and its child, so retain it through the code/token exchange on Windows.
+ #[cfg(windows)]
+ _master: Box,
+ #[cfg(windows)]
+ cursor_reported: bool,
}
/// How long to wait for the CLI to show the URL. Machine time: a container start and an HTTP call.
@@ -298,6 +304,10 @@ impl SigningIn {
child,
writer,
output,
+ #[cfg(windows)]
+ _master: pty.master,
+ #[cfg(windows)]
+ cursor_reported: false,
};
let url = signing
.wait_for(authorize_url_in, PATIENCE_FOR_THE_LINK)
@@ -387,6 +397,16 @@ impl SigningIn {
let began = Instant::now();
while began.elapsed() < patience {
if let Ok(seen) = self.output.lock() {
+ // portable-pty enables ConPTY's INHERIT_CURSOR flag. It waits for this
+ // reply before emitting the child's output, and can hang on close without it.
+ // This hidden terminal starts at 1;1. Accumulating output also handles a
+ // query split across reads; reply once to the initial inheritance request.
+ #[cfg(windows)]
+ if !self.cursor_reported && seen.contains("\x1b[6n") {
+ self.writer.write_all(b"\x1b[1;1R").ok()?;
+ self.writer.flush().ok()?;
+ self.cursor_reported = true;
+ }
if let Some(value) = found(&seen) {
return Some(value);
}
@@ -740,6 +760,62 @@ mod tests {
use super::*;
use crate::engine::Engine;
+ #[test]
+ #[cfg(windows)]
+ fn windows_claude_sign_in_keeps_its_terminal_until_the_flow_finishes() {
+ if crate::test_support::isolated_process(
+ "plan::tests::windows_claude_sign_in_keeps_its_terminal_until_the_flow_finishes",
+ ) {
+ return;
+ }
+ let root = crate::test_support::temp_root("claude-terminal-lifetime");
+ std::fs::create_dir_all(&root).unwrap();
+ let source = root.join("podman.rs");
+ std::fs::write(
+ &source,
+ r#"use std::io::{Read, Write};
+ fn main() {
+ print!("\x1b]8;;https://claude.ai/oauth/authorize?synthetic=terminal-lifetime\x1b\\Sign in\x1b]8;;\x1b\\\r\n");
+ std::io::stdout().flush().unwrap();
+ let mut input = Vec::new();
+ std::io::stdin().read_to_end(&mut input).unwrap();
+ }"#,
+ )
+ .unwrap();
+ crate::test_support::compile_fixture(&source, &root.join("podman.exe"));
+ std::env::set_var("PATH", &root);
+
+ // Bound begin and cleanup, including any destructor run before either returns.
+ // The fixture prints a synthetic URL and waits; no provider or container is contacted.
+ let (sent, received) = std::sync::mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ let result = (|| {
+ let (mut signing, url) = SigningIn::begin(
+ &crate::engine::Address::new(Engine::Podman, None),
+ "synthetic-sign-in-image",
+ )?;
+ assert_eq!(
+ url,
+ "https://claude.ai/oauth/authorize?synthetic=terminal-lifetime"
+ );
+ assert!(
+ signing.child.try_wait().unwrap().is_none(),
+ "the login child must survive until the code can be supplied"
+ );
+ signing.stop();
+ drop(signing);
+ Ok::<_, String>(())
+ })();
+ let _ = sent.send(result);
+ });
+ received
+ .recv_timeout(Duration::from_secs(10))
+ .expect("begin and cleanup must finish without a terminal teardown deadlock")
+ .expect("the synthetic login should provide a URL");
+ worker.join().unwrap();
+ std::fs::remove_dir_all(root).unwrap();
+ }
+
#[test]
fn chatgpt_callback_uses_ipv4_only_for_windows_podman() {
for (engine, windows, hosts) in [
From 645e57e4f3e0d07d187e817b700409b871ccdf25 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Wed, 16 Sep 2026 08:06:32 -0700
Subject: [PATCH 4/5] test(desktop): wait for Windows sign-in console cleanup
Modern ClosePseudoConsole returns asynchronously. Keep the native fixture deadline around its output reader reaching EOF before removing the test executable.
Refs FOR-308
---
desktop/src-tauri/src/plan.rs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs
index c85ba4248..f2d4f0cda 100644
--- a/desktop/src-tauri/src/plan.rs
+++ b/desktop/src-tauri/src/plan.rs
@@ -802,8 +802,15 @@ mod tests {
signing.child.try_wait().unwrap().is_none(),
"the login child must survive until the code can be supplied"
);
+ let draining = std::sync::Arc::downgrade(&signing.output);
signing.stop();
drop(signing);
+ // Modern ClosePseudoConsole returns before its clients disconnect. The
+ // reader's EOF, not the master's drop, marks completed console cleanup.
+ // Keep this inside the deadline before deleting the fixture executable.
+ while draining.strong_count() != 0 {
+ std::thread::sleep(Duration::from_millis(10));
+ }
Ok::<_, String>(())
})();
let _ = sent.send(result);
From bc07ae834f52f9bdfb3163acb1acfb8e38fe7a64 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Wed, 16 Sep 2026 08:32:53 -0700
Subject: [PATCH 5/5] fix(desktop): recognize cursor-positioned plan tokens
Recognize the plan prefix after label punctuation even when terminal cursor controls provide visual spacing. Extend the native Windows fixture through 92-character code submission and complete token receipt.
Fixes FOR-308
---
desktop/src-tauri/src/plan.rs | 49 ++++++++++++++++++++++++++++-------
1 file changed, 40 insertions(+), 9 deletions(-)
diff --git a/desktop/src-tauri/src/plan.rs b/desktop/src-tauri/src/plan.rs
index f2d4f0cda..3d6c2321d 100644
--- a/desktop/src-tauri/src/plan.rs
+++ b/desktop/src-tauri/src/plan.rs
@@ -187,10 +187,22 @@ breaks the first time a hint or a colour is added, and breaking here means telli
approved in their browser that it failed.
*/
pub fn token_in(output: &str) -> Option {
- plain(output)
- .split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
- .map(|word| word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'))
- .find(|word| word.starts_with(PLAN_TOKEN_PREFIX) && word.len() > 30)
+ fn token_character(c: char) -> bool {
+ c.is_ascii_alphanumeric() || c == '-' || c == '_'
+ }
+
+ let text = plain(output);
+ // Cursor positioning can separate a label from its token visually without a space
+ // in the stream. Accept the prefix after punctuation, but not inside another word.
+ text.match_indices(PLAN_TOKEN_PREFIX)
+ .filter(|(at, _)| !matches!(text[..*at].chars().next_back(), Some(c) if token_character(c)))
+ .map(|(at, _)| {
+ text[at..]
+ .split(|c| !token_character(c))
+ .next()
+ .unwrap_or("")
+ })
+ .find(|token| token.len() > 30)
.map(str::to_string)
}
@@ -773,12 +785,18 @@ mod tests {
let source = root.join("podman.rs");
std::fs::write(
&source,
- r#"use std::io::{Read, Write};
+ r#"use std::io::Write;
fn main() {
print!("\x1b]8;;https://claude.ai/oauth/authorize?synthetic=terminal-lifetime\x1b\\Sign in\x1b]8;;\x1b\\\r\n");
+ println!("Paste code here if prompted");
std::io::stdout().flush().unwrap();
- let mut input = Vec::new();
- std::io::stdin().read_to_end(&mut input).unwrap();
+ let mut input = String::new();
+ std::io::stdin().read_line(&mut input).unwrap();
+ assert_eq!(input.trim(), format!("{}#{}", "c".repeat(43), "s".repeat(48)));
+ print!("Your OAuth token (valid for 1 year):");
+ std::io::stdout().flush().unwrap();
+ std::thread::sleep(std::time::Duration::from_millis(60));
+ println!("\x1b[40G\x1b[32msk-ant-oat01-{}\x1b[0m", "s".repeat(95));
}"#,
)
.unwrap();
@@ -803,8 +821,11 @@ mod tests {
"the login child must survive until the code can be supplied"
);
let draining = std::sync::Arc::downgrade(&signing.output);
- signing.stop();
- drop(signing);
+ let code = format!("{}#{}", "c".repeat(43), "s".repeat(48));
+ assert_eq!(
+ signing.finish(&code)?,
+ format!("sk-ant-oat01-{}", "s".repeat(95))
+ );
// Modern ClosePseudoConsole returns before its clients disconnect. The
// reader's EOF, not the master's drop, marks completed console cleanup.
// Keep this inside the deadline before deleting the fixture executable.
@@ -1034,6 +1055,16 @@ mod tests {
assert_eq!(authorize_url_in(""), None);
}
+ #[test]
+ fn token_after_a_cursor_positioned_label_is_found_in_full() {
+ let token = format!("{PLAN_TOKEN_PREFIX}01-{}", "s".repeat(95));
+ let output = format!(
+ "Your OAuth token (valid for 1 year):\x1b[1G\x1b[32m{token}\x1b[0m\nStore this token safely."
+ );
+ assert_eq!(token_in(&output), Some(token.clone()));
+ assert_eq!(token_in(&format!("other-{token}")), None);
+ }
+
/// The stripper has to survive what a TUI actually emits, including a bare ESC pair.
#[test]
fn escapes_come_out_and_the_words_stay() {