From ac6e3b1d654ca2b7d05f0b3fc320c56f465d3fa0 Mon Sep 17 00:00:00 2001 From: Shironex Date: Mon, 1 Jun 2026 12:33:01 +0200 Subject: [PATCH] Clone: fall back to SSH when an HTTPS remote fails auth and a key exists Mission Control derives the clone remote from the selected repo's origin, which is commonly an HTTPS URL, while a sandbox is typically provisioned with SSH auth only. Git never uses an SSH key for an https:// remote, so cloning a private repo fails with 'could not read Username for https://github.com: terminal prompts disabled'. When an HTTPS clone fails with a credentials error AND a private key is present under ~/.ssh, derive the equivalent SSH remote (git@host:owner/repo.git) and retry once. Surgical by design: public repos still clone over HTTPS (no auth failure, no fallback); non-auth failures (repo-not-found, network) surface as-is; URLs that already carried credentials are not silently switched. deriveSshFallbackRemote is a pure, unit-tested function; GitRpc gains an injectable sshDir (defaulting to ~/.ssh, matching SshRpc). Root cause also worth fixing upstream in Mission Control (send an SSH remote when the sandbox uses SSH auth, or support an HTTPS token); this is the agent-side safety net. --- src/__tests__/git-rpc.test.ts | 59 ++++++++++++++- src/git-rpc.ts | 130 ++++++++++++++++++++++++++++++---- 2 files changed, 173 insertions(+), 16 deletions(-) diff --git a/src/__tests__/git-rpc.test.ts b/src/__tests__/git-rpc.test.ts index dd52d50..b14e5ec 100644 --- a/src/__tests__/git-rpc.test.ts +++ b/src/__tests__/git-rpc.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { GitRpc } from "../git-rpc"; +import { GitRpc, deriveSshFallbackRemote } from "../git-rpc"; let workspace: string; let repo: string; @@ -132,6 +132,63 @@ describe("GitRpc.clone", () => { ).rejects.toThrow(/git clone failed/); }, 20_000); +}); + +describe("deriveSshFallbackRemote", () => { + let keyDir: string; + let emptyDir: string; + const AUTH_ERR = "fatal: could not read Username for 'https://github.com': terminal prompts disabled"; + + beforeAll(() => { + keyDir = fs.mkdtempSync(path.join(os.tmpdir(), "mc-ssh-key-")); + fs.writeFileSync(path.join(keyDir, "id_ed25519"), "PRIVATE KEY\n", { mode: 0o600 }); + fs.writeFileSync(path.join(keyDir, "id_ed25519.pub"), "ssh-ed25519 AAAA\n"); + fs.writeFileSync(path.join(keyDir, "known_hosts"), "github.com ssh-ed25519 AAAA\n"); + emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "mc-ssh-nokey-")); + fs.writeFileSync(path.join(emptyDir, "known_hosts"), "github.com ssh-ed25519 AAAA\n"); + }); + + afterAll(() => { + fs.rmSync(keyDir, { recursive: true, force: true }); + fs.rmSync(emptyDir, { recursive: true, force: true }); + }); + + it("maps an HTTPS GitHub auth failure to the SSH remote when a key exists", () => { + expect(deriveSshFallbackRemote("https://github.com/x-lunofi-studio/lunofi-newsletter", AUTH_ERR, keyDir)).toBe( + "git@github.com:x-lunofi-studio/lunofi-newsletter", + ); + }); + + it("preserves a .git suffix and works for self-hosted GitLab subgroups", () => { + expect(deriveSshFallbackRemote("https://github.com/owner/repo.git", AUTH_ERR, keyDir)).toBe( + "git@github.com:owner/repo.git", + ); + expect(deriveSshFallbackRemote("https://gitlab.example.com/group/sub/repo", AUTH_ERR, keyDir)).toBe( + "git@gitlab.example.com:group/sub/repo", + ); + }); + + it("does NOT fall back for a non-auth failure (surfaces the real error)", () => { + const notFound = "remote: Repository not found.\nfatal: repository 'https://github.com/x/y' not found"; + expect(deriveSshFallbackRemote("https://github.com/x/y", notFound, keyDir)).toBeNull(); + }); + + it("does NOT fall back when no SSH private key is present", () => { + expect(deriveSshFallbackRemote("https://github.com/owner/repo", AUTH_ERR, emptyDir)).toBeNull(); + }); + + it("does NOT fall back for SSH or already-credentialed remotes", () => { + expect(deriveSshFallbackRemote("git@github.com:owner/repo.git", AUTH_ERR, keyDir)).toBeNull(); + expect(deriveSshFallbackRemote("https://user:pw@github.com/owner/repo", AUTH_ERR, keyDir)).toBeNull(); + }); + + it("does NOT fall back for a host-root URL with no owner/repo path", () => { + expect(deriveSshFallbackRemote("https://github.com/", AUTH_ERR, keyDir)).toBeNull(); + expect(deriveSshFallbackRemote("https://github.com/onlyowner", AUTH_ERR, keyDir)).toBeNull(); + }); +}); + +describe("GitRpc.clone (credential scrubbing)", () => { it("scrubs embedded credentials from a clone failure message", async () => { // Unreachable host:port so git fails fast (GIT_TERMINAL_PROMPT=0); the URL it // echoes in stderr must not carry the password back to the caller/UI. diff --git a/src/git-rpc.ts b/src/git-rpc.ts index 28c9d2e..9b042ce 100644 --- a/src/git-rpc.ts +++ b/src/git-rpc.ts @@ -1,5 +1,7 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { parsePorcelainZ, changedFileCount, @@ -30,6 +32,81 @@ const SSH_SCP_REMOTE = new RegExp( type RunGitResult = { stdout: string; stderr: string; code: number }; type RunGitOptions = { timeoutMs?: number; extraEnv?: Record }; +// Transport/auth env shared by the clone attempt and the SSH fallback retry. +// `--` separates options from the remote; GIT_ALLOW_PROTOCOL pins transports; +// GIT_TERMINAL_PROMPT=0 makes auth failures fail fast instead of blocking the RPC +// on an (invisible, in-container) username/password prompt. +const CLONE_EXTRA_ENV: Record = { + GIT_ALLOW_PROTOCOL: "http:https:ssh", + GIT_PROTOCOL_FROM_USER: "0", + GIT_TERMINAL_PROMPT: "0", + GIT_SSH_COMMAND: "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", +}; + +// git stderr markers that mean an HTTPS remote needs credentials we don't have. +// Narrow on purpose: a "repository not found" or network error must NOT trigger +// the SSH fallback — only a genuine missing-credentials failure should. +const HTTPS_AUTH_FAILURE_RE = + /could not read Username|terminal prompts disabled|Authentication failed|Invalid username or password|fatal: Authentication/i; + +// Names under ~/.ssh that are not usable private keys. +const NON_PRIVATE_KEY_SSH_FILES = new Set([ + "known_hosts", + "known_hosts2", + "config", + "authorized_keys", + "authorized_keys2", +]); + +function hasSshPrivateKey(sshDir: string): boolean { + let entries: string[]; + try { + entries = fs.readdirSync(sshDir); + } catch { + return false; + } + return entries.some((name) => !name.endsWith(".pub") && !NON_PRIVATE_KEY_SSH_FILES.has(name)); +} + +/** + * Mission Control derives the clone remote from the selected repo's `origin`, + * which is commonly an HTTPS URL, while a sandbox is typically provisioned with + * SSH auth only (a generated/copied key under ~/.ssh). Git never uses an SSH key + * for an `https://` remote, so a private repo fails with + * "could not read Username for 'https://github.com': terminal prompts disabled". + * + * When that specific auth failure happens AND a private key is present, derive + * the equivalent SSH remote (`git@host:owner/repo.git`) so the clone can be + * retried over SSH with the key the user already set up. Returns null when a + * fallback doesn't apply — non-HTTP(S) remote, a non-auth failure (e.g. + * repo-not-found: surface the real error), a URL that already carried + * credentials, no key on disk, or a host/path that can't form a valid SSH + * remote. Public HTTPS repos never reach here because they don't fail auth. + */ +export function deriveSshFallbackRemote( + remote: string, + stderr: string, + sshDir: string, +): string | null { + if (!HTTPS_AUTH_FAILURE_RE.test(stderr)) return null; + let u: URL; + try { + u = new URL(remote); + } catch { + return null; + } + if (u.protocol !== "https:" && u.protocol !== "http:") return null; + // If creds were supplied and still failed, don't silently switch transport — + // that would mask a real authentication problem. + if (u.username || u.password) return null; + const host = u.hostname; + const repoPath = u.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + if (!SSH_HOST.test(host) || !SSH_REPO_PATH.test(repoPath)) return null; + if (!hasSshPrivateKey(sshDir)) return null; + const ssh = `git@${host}:${repoPath}`; + return SSH_SCP_REMOTE.test(ssh) ? ssh : null; +} + /** * Spawn git directly (no shell) with a MINIMAL, allowlisted environment — never * the agent's full process.env, so MC_PAIRING_TOKEN / MC_* and other secrets are @@ -141,7 +218,12 @@ export class GitRpc { /** Slugs with a clone in flight — guards the existsSync TOCTOU under retries. */ private readonly cloning = new Set(); - constructor(private readonly workspaceRoot: string) {} + constructor( + private readonly workspaceRoot: string, + // Where provisioned SSH keys live (must match SshRpc's dir). Used to decide + // whether an HTTPS auth failure can fall back to an SSH clone. + private readonly sshDir: string = path.join(os.homedir(), ".ssh"), + ) {} private repoCwd(repo: string): string { const abs = resolveInsideWorkspace(this.workspaceRoot, repo); @@ -243,24 +325,42 @@ export class GitRpc { const startedAt = Date.now(); log("info", "git.clone.start", { slug, remote: redactRemote(remote) }); try { - // `--` separates options from the remote; GIT_ALLOW_PROTOCOL pins transports; - // GIT_TERMINAL_PROMPT=0 makes auth failures fail fast instead of blocking the - // RPC on an (invisible, in-container) username/password prompt. const r = await runGit(this.workspaceRoot, ["clone", "--", remote, slug], { timeoutMs: CLONE_TIMEOUT_MS, - extraEnv: { - GIT_ALLOW_PROTOCOL: "http:https:ssh", - GIT_PROTOCOL_FROM_USER: "0", - GIT_TERMINAL_PROMPT: "0", - GIT_SSH_COMMAND: "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", - }, + extraEnv: CLONE_EXTRA_ENV, }); - if (r.code !== 0) { - log("error", "git.clone.fail", { slug, durationMs: Date.now() - startedAt }); - throw new Error(`git clone failed: ${scrubCloneError(r.stderr, remote) || `exit ${r.code}`}`); + if (r.code === 0) { + log("info", "git.clone.ok", { slug, durationMs: Date.now() - startedAt }); + return { slug, path: dest }; } - log("info", "git.clone.ok", { slug, durationMs: Date.now() - startedAt }); - return { slug, path: dest }; + + // The remote (commonly Mission Control's HTTPS origin) failed for lack of + // credentials, but an SSH key is provisioned — retry once over SSH so the + // key is actually used. Non-auth failures return null here and surface as-is. + const sshRemote = deriveSshFallbackRemote(remote, r.stderr, this.sshDir); + if (sshRemote) { + // git may leave a partial dir from the failed attempt; dest was verified + // empty/absent up front, so clearing it before the retry is safe. + try { + fs.rmSync(dest, { recursive: true, force: true }); + } catch { + /* best effort */ + } + log("info", "git.clone.ssh_fallback", { slug, remote: redactRemote(sshRemote) }); + const r2 = await runGit(this.workspaceRoot, ["clone", "--", sshRemote, slug], { + timeoutMs: CLONE_TIMEOUT_MS, + extraEnv: CLONE_EXTRA_ENV, + }); + if (r2.code === 0) { + log("info", "git.clone.ok", { slug, durationMs: Date.now() - startedAt, viaSshFallback: true }); + return { slug, path: dest }; + } + log("error", "git.clone.fail", { slug, durationMs: Date.now() - startedAt, viaSshFallback: true }); + throw new Error(`git clone failed: ${scrubCloneError(r2.stderr, sshRemote) || `exit ${r2.code}`}`); + } + + log("error", "git.clone.fail", { slug, durationMs: Date.now() - startedAt }); + throw new Error(`git clone failed: ${scrubCloneError(r.stderr, remote) || `exit ${r.code}`}`); } finally { this.cloning.delete(slug); }