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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 09:27:22 -07:00
co-authored by Claude Opus 5.5
parent 7f3fe8cded
commit d84637fd39
3 changed files with 549 additions and 4 deletions
@@ -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<u8>,
/// 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;
+544
View File
@@ -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 <host> 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<String> {
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<String, String> {
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"
);
}
}
}
+1
View File
@@ -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;