From d84637fd3960727a68b964889602c5cf4c43823b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 09:27:22 -0700 Subject: [PATCH] Marketplace: GitHub sign-in through gh inside a container Drives `gh auth login --web` in a running project container over an attached pty, with GH_CONFIG_DIR/GIT_CONFIG_GLOBAL in a temp dir that is removed on exit (also on HUP/INT/TERM), emits the one-time code and redacted output lines, and returns the token read back between markers. Host validation reuses auth::valid_host plus a no-port check (F13); a cancel or timeout also pkills the in-container login (N9). Reuses the setup-token flow's AnsiStripper, push_capped_tail and Enter delay, made pub(crate) without behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/auth_token_commands.rs | 8 +- app/src-tauri/src/marketplace/gh_login.rs | 544 ++++++++++++++++++ app/src-tauri/src/marketplace/mod.rs | 1 + 3 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 app/src-tauri/src/marketplace/gh_login.rs diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs index 34c251b..6e057c5 100644 --- a/app/src-tauri/src/commands/auth_token_commands.rs +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -114,7 +114,7 @@ const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60); /// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart /// already submits reliably; this leaves headroom for the extra hops through /// Docker's exec socket, which can merge writes that arrive close together. -const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250); +pub(crate) const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250); /// Documented shape of a `setup-token` credential. const TOKEN_PREFIX: &str = "sk-ant-oat01-"; @@ -621,7 +621,7 @@ const MAX_ANSI_CARRY: usize = 64 * 1024; /// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete /// trailing sequence over to the next chunk. #[derive(Default)] -struct AnsiStripper { +pub(crate) struct AnsiStripper { carry: Vec, /// OSC 8 link targets seen since the last [`AnsiStripper::take_links`]. /// Kept out of the return value so every existing caller and test of @@ -630,7 +630,7 @@ struct AnsiStripper { } impl AnsiStripper { - fn push(&mut self, chunk: &[u8]) -> String { + pub(crate) fn push(&mut self, chunk: &[u8]) -> String { self.carry.extend_from_slice(chunk); let (mut out, links, consumed) = strip_ansi_prefix(&self.carry); self.record_links(links); @@ -734,7 +734,7 @@ const REJECTION_SCAN_WINDOW: usize = 4096; const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"]; /// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail. -fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) { +pub(crate) fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) { buf.push_str(chunk); if buf.len() <= cap { return; diff --git a/app/src-tauri/src/marketplace/gh_login.rs b/app/src-tauri/src/marketplace/gh_login.rs new file mode 100644 index 0000000..97c9ccf --- /dev/null +++ b/app/src-tauri/src/marketplace/gh_login.rs @@ -0,0 +1,544 @@ +//! GitHub sign-in through `gh auth login --web` inside a running container, for +//! hosts that have no `gh` of their own. The token is read back through the +//! exec, returned to the caller for the keychain, and never emitted, logged or +//! left behind in the container. + +use std::time::Duration; + +use futures_util::StreamExt; +use tauri::{AppHandle, Emitter}; +use tokio::io::AsyncWriteExt; +use tokio::sync::oneshot; + +use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY}; +use crate::docker::exec::{ + create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec, +}; + +pub const CODE_EVENT: &str = "marketplace-gh-login-code"; +pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output"; + +const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const TOKEN_BEGIN: &str = "__TRIPLEC_TOKEN_BEGIN__"; +const TOKEN_END: &str = "__TRIPLEC_TOKEN_END__"; +/// Common prefix of both markers: any line containing it is never shown. +const TOKEN_MARKER: &str = "__TRIPLEC_TOKEN"; +const MAX_TRANSCRIPT: usize = 64 * 1024; +const MAX_PENDING_LINE: usize = 4096; + +/// Pre-flight N9: on cancel or timeout the attach is dropped, but `gh auth +/// login` would keep polling in the container. This matches both it and the +/// script around it (whose text contains the same words); errors are ignored. +const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"]; + +/// Constant script; the host is `$1` (argv, never interpolated), because +/// `create_attached_exec_as` takes no env. +/// +/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit, +/// so the container is never left logged in. The `HUP INT TERM` trap turns a +/// signal (the pty closing, or the cancel `pkill`) into a normal exit so the +/// `EXIT` trap still runs — `sh` skips it when killed outright. +/// * `--git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with +/// your GitHub credentials?" prompt, which `https` triggers and which would +/// write a credential helper into the git config. +/// * `BROWSER=true` makes gh's "open the browser" step a no-op. +const GH_LOGIN_SCRIPT: &str = r#"set -eu +host="$1" +case "$host" in + '' | -* | *[!A-Za-z0-9.-]*) echo "invalid host" >&2; exit 2 ;; +esac +export HOME=/home/claude +d=$(mktemp -d) +trap 'rm -rf "$d"' EXIT +trap 'exit 130' HUP INT TERM +export GH_CONFIG_DIR="$d" GIT_CONFIG_GLOBAL="$d/gitconfig" BROWSER=true +gh auth login --hostname "$host" --web --git-protocol ssh --skip-ssh-key --scopes repo +t=$(gh auth token --hostname "$host") +printf '\n%s%s%s\n' __TRIPLEC_TOKEN_BEGIN__ "$t" __TRIPLEC_TOKEN_END__ +"#; + +/// Pre-flight F13: the shared host rule, minus ports — `gh auth login +/// --hostname` takes a bare name. +pub fn valid_host(host: &str) -> bool { + crate::marketplace::auth::valid_host(host) && !host.contains(':') +} + +/// Remove terminal control sequences and carriage returns from one complete +/// piece of text. An unterminated sequence at the end is dropped. The login +/// itself uses a streaming [`AnsiStripper`], which carries a sequence split +/// across chunks instead. +pub fn strip_ansi(s: &str) -> String { + AnsiStripper::default().push(s.as_bytes()) +} + +/// gh prints `! First copy your one-time code: XXXX-XXXX`, then either a URL +/// or "Press Enter to open in your browser". Returns (code, url). +pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> { + const LABEL: &str = "one-time code:"; + let at = output.find(LABEL)? + LABEL.len(); + let code: String = output[at..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if code.len() < 6 || !code.contains('-') { + return None; + } + let url = output + .split_whitespace() + .find(|w| w.starts_with("https://") && w.contains("/login/device")) + .map(|w| { + w.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '/') + .to_string() + }) + .unwrap_or_else(|| format!("https://{host}/login/device")); + Some((code, url)) +} + +pub fn extract_token(text: &str) -> Option { + let start = text.find(TOKEN_BEGIN)? + TOKEN_BEGIN.len(); + let end = start + text[start..].find(TOKEN_END)?; + let token = text[start..end].trim(); + if token.is_empty() || token.chars().any(|c| c.is_whitespace() || c.is_control()) { + return None; + } + Some(token.to_string()) +} + +/// Append `chunk` and hand back the complete lines, minus any line carrying the +/// token markers. A partial line waits in `pending` (so a marker split across +/// chunks is never shown), and is dropped if it grows past a bound. +pub fn take_display_lines(pending: &mut String, chunk: &str) -> String { + pending.push_str(chunk); + let Some(last_nl) = pending.rfind('\n') else { + if pending.len() > MAX_PENDING_LINE { + pending.clear(); + } + return String::new(); + }; + let complete: String = pending.drain(..=last_nl).collect(); + complete + .lines() + .filter(|l| !l.contains(TOKEN_MARKER)) + .map(|l| format!("{l}\n")) + .collect() +} + +/// What to show when the login ends without a token: the last few lines, with +/// any marker line removed. +fn failure_tail(transcript: &str) -> String { + let lines: Vec<&str> = transcript + .lines() + .filter(|l| !l.contains(TOKEN_MARKER) && !l.trim().is_empty()) + .collect(); + lines[lines.len().saturating_sub(5)..].join("\n") +} + +/// Pre-flight N9: stop the in-container login after a cancel or timeout. +async fn kill_container_login(container_id: &str) { + let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect(); + let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await; +} + +/// Run `gh auth login --web` in the container and return the token it minted. +pub async fn run_gh_container_login( + app: &AppHandle, + account_id: &str, + container_id: &str, + host: &str, + mut cancel: oneshot::Receiver<()>, +) -> Result { + if !valid_host(host) { + return Err(format!("{host:?} is not a valid host name.")); + } + let AttachedExec { + exec_id, + mut output, + mut input, + } = create_attached_exec_as( + container_id, + vec![ + "sh".to_string(), + "-c".to_string(), + GH_LOGIN_SCRIPT.to_string(), + "triple-c-gh-login".to_string(), + host.to_string(), + ], + true, + "claude", + "/home/claude", + ) + .await?; + + let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT; + let mut stripper = AnsiStripper::default(); + let mut transcript = String::new(); + let mut pending = String::new(); + let mut code_sent = false; + let mut enter_sent = false; + + loop { + let next = tokio::select! { + _ = &mut cancel => { + drop(output); + drop(input); + kill_container_login(container_id).await; + return Err("GitHub sign-in cancelled. Nothing was stored.".to_string()); + } + next = tokio::time::timeout_at(deadline, output.next()) => match next { + Ok(next) => next, + Err(_) => { + drop(output); + drop(input); + kill_container_login(container_id).await; + return Err(format!( + "Timed out after {} minutes waiting for the GitHub sign-in. Nothing was stored.", + LOGIN_TIMEOUT.as_secs() / 60 + )); + } + }, + }; + let frame = match next { + Some(Ok(frame)) => frame, + Some(Err(e)) => { + return Err(format!( + "Lost the connection to gh: {e}. Nothing was stored." + )) + } + None => break, + }; + let text = stripper.push(&frame.into_bytes()); + push_capped_tail(&mut transcript, &text, MAX_TRANSCRIPT); + + let shown = take_display_lines(&mut pending, &text); + if !shown.is_empty() { + let _ = app.emit( + OUTPUT_EVENT, + serde_json::json!({ "account_id": account_id, "chunk": shown }), + ); + } + if !code_sent { + if let Some((code, url)) = parse_device_prompt(&transcript, host) { + let _ = app.emit( + CODE_EVENT, + serde_json::json!({ "account_id": account_id, "code": code, "url": url }), + ); + code_sent = true; + } + } + if code_sent && !enter_sent && transcript.contains("Press Enter") { + // The Enter is its own write, after a pause (PR #64): arriving with + // other bytes it can be read as part of a paste and swallowed. + tokio::time::sleep(SUBMIT_ENTER_DELAY).await; + input + .write_all(b"\r") + .await + .map_err(|e| format!("Could not answer gh's prompt: {e}. Nothing was stored."))?; + let _ = input.flush().await; + enter_sent = true; + } + } + + let status = wait_for_exec_exit(&exec_id).await; + if let Some(token) = extract_token(&transcript) { + return Ok(token); + } + Err(format!( + "gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}", + status + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + failure_tail(&transcript) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GH_PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\nPress Enter to open github.com in your browser... "; + + #[test] + fn the_device_code_is_read_and_the_url_defaults_to_the_host() { + assert_eq!( + parse_device_prompt(GH_PROMPT, "github.com"), + Some(( + "4F2A-9C1B".to_string(), + "https://github.com/login/device".to_string() + )) + ); + } + + #[test] + fn an_explicit_device_url_wins() { + let out = "! First copy your one-time code: AB12-CD34\nOpen this URL to continue in your web browser: https://ghe.example.com/login/device\n"; + assert_eq!( + parse_device_prompt(out, "ghe.example.com"), + Some(( + "AB12-CD34".to_string(), + "https://ghe.example.com/login/device".to_string() + )) + ); + } + + #[test] + fn no_code_yet_means_no_prompt() { + assert_eq!( + parse_device_prompt("! First copy your one-time", "github.com"), + None + ); + assert_eq!(parse_device_prompt("", "github.com"), None); + } + + #[test] + fn the_token_is_taken_from_between_the_markers() { + let out = "✓ Logged in\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n"; + assert_eq!(extract_token(out), Some("test-token-not-real".to_string())); + assert_eq!( + extract_token("__TRIPLEC_TOKEN_BEGIN__test-token-not-real"), + None, + "unterminated" + ); + assert_eq!( + extract_token("__TRIPLEC_TOKEN_BEGIN____TRIPLEC_TOKEN_END__"), + None, + "empty" + ); + assert_eq!( + extract_token("__TRIPLEC_TOKEN_BEGIN__a b__TRIPLEC_TOKEN_END__"), + None, + "whitespace" + ); + } + + #[test] + fn only_complete_lines_are_shown_and_the_token_line_never_is() { + let mut pending = String::new(); + assert_eq!( + take_display_lines(&mut pending, "! First copy your one-"), + "" + ); + assert_eq!( + take_display_lines(&mut pending, "time code: 4F2A-9C1B\nPress"), + "! First copy your one-time code: 4F2A-9C1B\n" + ); + assert_eq!(pending, "Press"); + let shown = take_display_lines( + &mut pending, + " Enter\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\ndone\n", + ); + assert_eq!(shown, "Press Enter\ndone\n"); + assert!(!shown.contains("test-token-not-real")); + } + + #[test] + fn escape_sequences_and_carriage_returns_are_removed() { + assert_eq!(strip_ansi("\u{1b}[1;32m✓\u{1b}[0m done\r\n"), "✓ done\n"); + assert_eq!( + strip_ansi("a\u{1b}]8;;https://x\u{7}link\u{1b}]8;;\u{7}b"), + "alinkb" + ); + assert_eq!(strip_ansi("cut\u{1b}["), "cut"); + } + + #[test] + fn hosts_are_plain_names() { + assert!(valid_host("github.com")); + assert!(valid_host("ghe.corp-1.example")); + for bad in ["", "-x", "a b", "a;b", "a/b", "$(id)"] { + assert!(!valid_host(bad), "{bad:?}"); + } + } + + /// Pre-flight F13: the shared `auth::valid_host` accepts `host:port`, but + /// `gh auth login --hostname` takes a bare name, so a port is refused here. + #[test] + fn hosts_with_a_port_are_refused() { + assert!(crate::marketplace::auth::valid_host("ghe.corp:8443")); + assert!(!valid_host("ghe.corp:8443")); + assert!(!valid_host("ghe.corp:")); + } + + #[test] + fn the_failure_tail_never_carries_the_token() { + let transcript = "! First copy your one-time code: 4F2A-9C1B\n\ + __TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n\ + error: something odd\n"; + let tail = failure_tail(transcript); + assert!(!tail.contains("test-token-not-real")); + assert!(tail.contains("error: something odd")); + } + + /// Pre-flight N9: the cancel/timeout `pkill -f` pattern has to match the + /// `gh` command line the script runs. + #[test] + fn the_cancel_pattern_matches_the_script() { + assert_eq!(CANCEL_PKILL[0], "pkill"); + assert_eq!(CANCEL_PKILL[1], "-f"); + assert!(GH_LOGIN_SCRIPT.contains(CANCEL_PKILL[2])); + } + + /// The script end to end against a stand-in `gh`, as a login would run it + /// inside the container (minus Docker). + #[cfg(unix)] + mod script { + use super::super::*; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use std::process::{Command, Stdio}; + + /// A fake `gh` that records its environment into `log_dir` and prints + /// the fixture token for `auth token`. `login_body` runs for `auth login`. + fn fake_gh(dir: &Path, log_dir: &Path, login_body: &str) -> PathBuf { + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let gh = bin.join("gh"); + std::fs::write( + &gh, + format!( + "#!/bin/sh\n\ + log='{log}'\n\ + case \"$1 $2\" in\n\ + 'auth login')\n\ + printf '%s\\n' \"$GH_CONFIG_DIR\" > \"$log/config_dir\"\n\ + printf '%s\\n' \"$GIT_CONFIG_GLOBAL\" > \"$log/git_config\"\n\ + printf '%s\\n' \"$BROWSER\" > \"$log/browser\"\n\ + printf '%s\\n' \"$*\" > \"$log/args\"\n\ + echo 'token-in-config' > \"$GH_CONFIG_DIR/hosts.yml\"\n\ + {login}\n\ + ;;\n\ + 'auth token') echo test-token-not-real ;;\n\ + *) exit 9 ;;\n\ + esac\n", + log = log_dir.display(), + login = login_body, + ), + ) + .unwrap(); + std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin + } + + fn script_command(bin: &Path, tmp: &Path, host: &str) -> Command { + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(GH_LOGIN_SCRIPT) + .arg("triple-c-gh-login") + .arg(host) + .env( + "PATH", + format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()), + ) + .env("TMPDIR", tmp); + cmd + } + + fn read(p: PathBuf) -> String { + std::fs::read_to_string(p).unwrap().trim().to_string() + } + + #[test] + fn the_token_comes_back_and_the_temp_config_is_gone() { + let root = tempfile::tempdir().unwrap(); + let log = root.path().join("log"); + let tmp = root.path().join("tmp"); + std::fs::create_dir_all(&log).unwrap(); + std::fs::create_dir_all(&tmp).unwrap(); + let bin = fake_gh(root.path(), &log, "echo '✓ Logged in'"); + + let out = script_command(&bin, &tmp, "github.com").output().unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + extract_token(&stdout), + Some("test-token-not-real".to_string()) + ); + + let config_dir = read(log.join("config_dir")); + assert!( + config_dir.starts_with(tmp.to_str().unwrap()), + "{config_dir}" + ); + assert!( + !Path::new(&config_dir).exists(), + "temp GH_CONFIG_DIR left behind" + ); + assert_eq!( + read(log.join("git_config")), + format!("{config_dir}/gitconfig") + ); + assert_eq!(read(log.join("browser")), "true"); + assert_eq!( + read(log.join("args")), + "auth login --hostname github.com --web --git-protocol ssh --skip-ssh-key --scopes repo" + ); + assert_eq!(std::fs::read_dir(&tmp).unwrap().count(), 0); + } + + #[test] + fn the_script_refuses_a_bad_host_on_its_own() { + let root = tempfile::tempdir().unwrap(); + let log = root.path().join("log"); + std::fs::create_dir_all(&log).unwrap(); + let bin = fake_gh(root.path(), &log, "true"); + for bad in ["", "-x", "a;b", "$(id)", "a:1"] { + let out = script_command(&bin, root.path(), bad).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{bad:?}"); + assert!(!log.join("args").exists(), "gh ran for {bad:?}"); + } + } + + /// Pre-flight N9: a cancel `pkill`s the login; the temp config must + /// still be removed when the script dies by signal. + #[test] + fn a_killed_login_still_removes_the_temp_config() { + use std::os::unix::process::CommandExt; + + let root = tempfile::tempdir().unwrap(); + let log = root.path().join("log"); + let tmp = root.path().join("tmp"); + std::fs::create_dir_all(&log).unwrap(); + std::fs::create_dir_all(&tmp).unwrap(); + let bin = fake_gh(root.path(), &log, "touch \"$log/started\"; sleep 30"); + + let mut child = script_command(&bin, &tmp, "github.com") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + let started = log.join("started"); + for _ in 0..200 { + if started.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + assert!(started.exists(), "fake gh never started"); + let config_dir = read(log.join("config_dir")); + assert!(Path::new(&config_dir).exists()); + + // Like `pkill -f`, which matches both the script and gh. + let pgid = child.id().to_string(); + let killed = Command::new("kill") + .args(["-s", "TERM", "--", &format!("-{pgid}")]) + .status() + .unwrap(); + assert!(killed.success(), "kill failed"); + let sent = std::time::Instant::now(); + child.wait().unwrap(); + assert!( + sent.elapsed() < std::time::Duration::from_secs(10), + "the script outlived the signal" + ); + assert!( + !Path::new(&config_dir).exists(), + "temp GH_CONFIG_DIR left behind" + ); + } + } +} diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index 39aa076..17f3f74 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod catalog; pub mod diff; +pub mod gh_login; pub mod git; pub mod payload; pub mod tree;