/** * @file A user's OWN git identity — the SSH key and the HTTPS logins a clone * (and every later fetch/pull/push in the cloned repo) authenticates with — * and the clone itself (`cloneRepo`, behind `POST /api/lanes/clone`). * * Whose identity is decided by an ACCOUNT, never by the request: * - a member (S3, `run-as.js`): everything lives in their own home, under * `~/.config/agenthub/` — `ssh/id_ed25519` and/or `ssh/id_rsa` (+ `.pub`) and `git-credentials` * (git's own `credential-store` format) — written only AS the member * (`write-as`, 0600 for the secrets), read only through `readFileSafe`. * Their git runs as them, so it reads these itself; no secret ever crosses * sudo's argv or env (sudo logs both). * - the host (local mode, an admin's folder outside the users root): the * shared store of `git-credentials.js` for HTTPS, and one key under * `/agenthub-ssh/` (0700/0600) for SSH. * * The key pair is made here (Node's ed25519) and written in OpenSSH's own * format, because a member's sudoers allows git but not `ssh-keygen`. Only the * PUBLIC half is ever returned. A clone persists the identity into the new * repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a * `credential.helper=store --file=` — so an agent's push * from a pane uses the same login without touching the user's global config. * A plain `git` typed in a terminal reads none of that, so the account's own * `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on * by default for a member, a switch for the host; a user's own is never * overwritten), plus SSH logins (`setSshUser`: AWS CodeCommit wants the IAM * SSH key ID as the user) as `url.<…>.insteadOf` rules there — one per host, * or one per ALIAS (`ssh://aws-prod/…`), so several accounts on one host * (two AWS accounts, two GitHub users) each log in as themselves. Besides * the default key of each type, an account may hold named keys * (`ssh/id__`), and a login may name the key it uses * (`agenthub..sshKey` in the same file): a clone of that login's URL * offers ONLY that key and keeps it in the clone's config. * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); const gitCredentials = require("./git-credentials"); const runAs = require("./run-as"); const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/; /** A clone may take a while on a big repo (~1 GB); past this it is killed. */ const CLONE_TIMEOUT_MS = 30 * 60_000; const HELPER = path.join(__dirname, "git-credential-helper.js"); const err = (code, message) => Object.assign(new Error(message), { code }); const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`; // ── Accounts ─────────────────────────────────────────────────────────────── /** `{kind: "member", acct}` or `{kind: "host"}` for the folder `dir`. */ function accountForDir(dir) { const acct = runAs.accountForPath(dir); return acct ? { kind: "member", acct } : { kind: "host" }; } /** * The identity a signed-in user manages in Settings: their own Linux account * under S3, else the host's — which only an admin (or local mode) may change. */ function accountForRequest(req) { const user = req?.user; if (user) { const acct = runAs.accountForUser(user.username); if (acct) return { kind: "member", acct }; } return { kind: "host", writable: !user || user.role === "admin" }; } /** * The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and * RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only * `ssh-rsa`). An account may hold one of EACH, in the file of its type, and * every ssh git runs offers both (ed25519 first). */ const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" }; const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" }; /** A named key's name, and every key's id (= its file name in `sshDir`). */ const KEY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; const KEY_ID_RE = /^id_(ed25519|rsa)(?:_([a-z0-9][a-z0-9-]{0,31}))?$/; /** ssh offers every key in turn and servers stop after ~6 tries. */ const MAX_KEYS = 10; const keyIdFor = (type, name) => (name ? `${KEY_FILES[type]}_${name}` : KEY_FILES[type]); function paths(account) { if (account.kind === "member") { const base = path.join(account.acct.home, ".config", "agenthub"); return { base, sshDir: path.join(base, "ssh"), key: path.join(base, "ssh", KEY_FILES.ed25519), store: path.join(base, "git-credentials"), }; } const sshDir = path.join(getClaudeHome(), "agenthub-ssh"); return { base: sshDir, sshDir, key: path.join(sshDir, KEY_FILES.ed25519), store: null }; } /** * A file's text, or null when it is missing. `strict`: any OTHER failure * (unreadable, not the member's own file) throws — a store about to be * rewritten must never be read as empty, or the rewrite drops every login. */ function readText(account, file, { strict = false } = {}) { try { return account.kind === "member" ? runAs.readFileSafe(file, "utf8", { max: 256 * 1024 }) : fs.readFileSync(file, "utf8"); } catch (e) { if (strict && e.code !== "ENOENT") { throw err("ESTOREUNREADABLE", `cannot read ${path.basename(file)}: ${e.code || e.message}`); } return null; } } /** Whether `file` is a regular file (a member's: one they own) — never read. */ function isOwnFile(account, file) { try { const st = fs.lstatSync(file); return st.isFile() && (account.kind !== "member" || st.uid === account.acct.uid); } catch { return false; } } /** Write `content` 0600 (`private`) or 0644, making the folder first. */ function writeText(account, file, content, { secret }) { if (account.kind === "member") { runAs.mkdirAs(account.acct, path.dirname(file)); runAs.writeAs(account.acct, file, secret ? "private" : "replace", content); return; } fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.agenthub-tmp`; fs.writeFileSync(tmp, content, { mode: secret ? 0o600 : 0o644 }); fs.renameSync(tmp, file); } function removeFile(account, file) { if (account.kind === "member") runAs.removeAs(account.acct, file); else fs.rmSync(file, { force: true }); } // ── SSH key ──────────────────────────────────────────────────────────────── const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b; }; const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]); /** An SSH mpint: big-endian, with a 0 byte in front when the top bit is set. */ const mpint = (b) => sshString(b[0] & 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b); /** * A fresh RSA 4096 pair: the private key as PKCS#1 PEM (`BEGIN RSA PRIVATE * KEY`, which OpenSSH reads), the public one as an `ssh-rsa` line. Made off * the event loop — 4096-bit generation takes a second. */ async function makeRsaKeyPair(comment) { const pair = await require("node:util").promisify(crypto.generateKeyPair)("rsa", { modulusLength: 4096, publicExponent: 0x10001, }); const jwk = pair.publicKey.export({ format: "jwk" }); const blob = Buffer.concat([ sshString(Buffer.from("ssh-rsa")), mpint(Buffer.from(jwk.e, "base64url")), mpint(Buffer.from(jwk.n, "base64url")), ]); return { privateKey: pair.privateKey.export({ type: "pkcs1", format: "pem" }), publicKey: `ssh-rsa ${blob.toString("base64")} ${comment}\n`, }; } /** * A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text, * unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal * to type a passphrase into. */ function makeKeyPair(comment) { const pair = crypto.generateKeyPairSync("ed25519"); const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url"); const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url"); return encodeEd25519(seed, pub, comment); } /** An ed25519 key (32-byte seed + public half) in OpenSSH's own formats. */ function encodeEd25519(seed, pub, comment) { const type = Buffer.from("ssh-ed25519"); const pubBlob = Buffer.concat([sshString(type), sshString(pub)]); const check = crypto.randomBytes(4); let priv = Buffer.concat([ check, check, sshString(type), sshString(pub), sshString(Buffer.concat([seed, pub])), sshString(Buffer.from(comment, "utf8")), ]); const pad = []; for (let i = 1; (priv.length + pad.length) % 8 !== 0; i++) pad.push(i); priv = Buffer.concat([priv, Buffer.from(pad)]); const body = Buffer.concat([ Buffer.from("openssh-key-v1\0"), sshString(Buffer.from("none")), sshString(Buffer.from("none")), sshString(Buffer.alloc(0)), u32(1), sshString(pubBlob), sshString(priv), ]); const b64 = body.toString("base64").replace(/.{70}/g, "$&\n"); return { privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.replace(/\n$/, "")}\n-----END OPENSSH PRIVATE KEY-----\n`, publicKey: `ssh-ed25519 ${pubBlob.toString("base64")} ${comment}\n`, }; } /** `ssh-rsa …` for an RSA key object (private or public). */ function rsaPublicLine(key, comment) { const jwk = crypto.createPublicKey(key).export({ format: "jwk" }); const blob = Buffer.concat([ sshString(Buffer.from("ssh-rsa")), mpint(Buffer.from(jwk.e, "base64url")), mpint(Buffer.from(jwk.n, "base64url")), ]); return `ssh-rsa ${blob.toString("base64")} ${comment}\n`; } // ── An imported key ──────────────────────────────────────────────────────── /** A pasted key file is a few KB; anything far past that is not one. */ const IMPORT_MAX = 32 * 1024; const PASSPHRASE_HINT = "this key has a passphrase — a commit made from the dashboard has nowhere to type it. " + 'Make a copy without one on your own machine (ssh-keygen -p -N "" -f ) and import the copy'; /** Reads SSH wire strings out of a buffer; EBADKEY on a short one. */ function wireReader(buf) { let at = 0; const need = (n) => { if (at + n > buf.length) throw err("EBADKEY", "not a valid SSH private key"); }; return { u32() { need(4); const v = buf.readUInt32BE(at); at += 4; return v; }, bytes() { const n = this.u32(); need(n); const v = buf.subarray(at, at + n); at += n; return v; }, }; } const b64u = (b) => Buffer.from(b).toString("base64url"); const big = (b) => BigInt(`0x${Buffer.from(b).toString("hex") || "0"}`); const unbig = (n) => { let h = n.toString(16); if (h.length % 2) h = `0${h}`; return Buffer.from(h, "hex"); }; /** An SSH mpint's magnitude without its sign byte. */ const mag = (b) => (b[0] === 0 && b.length > 1 ? b.subarray(1) : b); /** * An `openssh-key-v1` file (`BEGIN OPENSSH PRIVATE KEY`) as a KeyObject plus * its comment. Encrypted ones are refused (EKEYPASSPHRASE): reading them * needs the passphrase, and signing from the dashboard would need it again. */ function parseOpenSsh(text) { const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, "").replace(/\s+/g, ""); const buf = Buffer.from(b64, "base64"); const magic = Buffer.from("openssh-key-v1\0"); if (!buf.subarray(0, magic.length).equals(magic)) { throw err("EBADKEY", "not a valid SSH private key"); } const r = wireReader(buf.subarray(magic.length)); const cipher = r.bytes().toString(); r.bytes(); // kdf name r.bytes(); // kdf options if (cipher !== "none") throw err("EKEYPASSPHRASE", PASSPHRASE_HINT); if (r.u32() !== 1) throw err("EBADKEY", "a key file holding one key"); r.bytes(); // the public blob — checked against the private half below const p = wireReader(r.bytes()); if (p.u32() !== p.u32()) throw err("EBADKEY", "not a valid SSH private key"); const type = p.bytes().toString(); if (type === "ssh-ed25519") { const pub = p.bytes(); const sk = p.bytes(); if (pub.length !== 32 || sk.length !== 64 || !sk.subarray(32).equals(pub)) { throw err("EBADKEY", "not a valid SSH private key"); } const key = crypto.createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d: b64u(sk.subarray(0, 32)), x: b64u(pub) }, format: "jwk", }); return { key, comment: p.bytes().toString("utf8") }; } if (type === "ssh-rsa") { const n = mag(p.bytes()); const e = mag(p.bytes()); const d = mag(p.bytes()); const qi = mag(p.bytes()); const pp = mag(p.bytes()); const q = mag(p.bytes()); const D = big(d); const key = crypto.createPrivateKey({ key: { kty: "RSA", n: b64u(n), e: b64u(e), d: b64u(d), p: b64u(pp), q: b64u(q), dp: b64u(unbig(D % (big(pp) - 1n))), dq: b64u(unbig(D % (big(q) - 1n))), qi: b64u(qi), }, format: "jwk", }); return { key, comment: p.bytes().toString("utf8") }; } throw err("EBADKEYTYPE", `an ${type.replace(/^ssh-/, "")} key — import an ed25519 or RSA one`); } /** * A pasted or uploaded private key → `{type, privateKey, publicKey}` in the * same formats a generated key is written in. Takes OpenSSH's own format and * the PEM ones (PKCS#1, PKCS#8); refuses an encrypted key (EKEYPASSPHRASE), * anything but ed25519 or RSA ≥ 2048 bits (EBADKEYTYPE), and a key whose * halves do not sign and verify as one (EBADKEY). */ function parsePrivateKey(text, fallbackComment) { const src = typeof text === "string" ? text.replace(/\r\n?/g, "\n").trim() : ""; if (!src || src.length > IMPORT_MAX || !/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(src)) { throw err( "EBADKEY", /^ssh-|^ecdsa-/.test(src) ? "that is a PUBLIC key (.pub) — import the private key file next to it" : "not an SSH private key — the file starts with -----BEGIN … PRIVATE KEY-----" ); } let key; let comment = ""; if (/-----BEGIN OPENSSH PRIVATE KEY-----/.test(src)) { ({ key, comment } = parseOpenSsh(src)); } else { if (/ENCRYPTED/.test(src)) throw err("EKEYPASSPHRASE", PASSPHRASE_HINT); try { key = crypto.createPrivateKey(src); } catch (e) { if (/passphrase/i.test(String(e?.code || e?.message))) { throw err("EKEYPASSPHRASE", PASSPHRASE_HINT); } throw err("EBADKEY", "not a valid SSH private key"); } } const kind = key.asymmetricKeyType; if (kind !== "ed25519" && kind !== "rsa") { throw err("EBADKEYTYPE", `an ${kind || "unknown"} key — import an ed25519 or RSA one`); } if (kind === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { throw err("EBADKEYTYPE", "an RSA key under 2048 bits — too weak to sign with"); } // The halves belong together: a mangled paste fails here, not at commit time. const probe = Buffer.from("agenthub key check"); const hash = kind === "rsa" ? "sha256" : null; if (!crypto.verify(hash, probe, crypto.createPublicKey(key), crypto.sign(hash, probe, key))) { throw err("EBADKEY", "the key's private and public halves do not match"); } const label = String(comment || fallbackComment || "agenthub") .replace(/[^\w.@+-]/g, "") .slice(0, 64) || "agenthub"; if (kind === "ed25519") { const jwk = key.export({ format: "jwk" }); return { type: "ed25519", ...encodeEd25519(Buffer.from(jwk.d, "base64url"), Buffer.from(jwk.x, "base64url"), label), }; } return { type: "rsa", privateKey: key.export({ type: "pkcs1", format: "pem" }), publicKey: rsaPublicLine(key, label), }; } /** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */ function fingerprint(publicKey) { const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64"); return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`; } /** * `[{id, type, name, file, publicKey}]` of the account's keys: the default * ed25519 and rsa ones first (`name` null), then the named ones by name. */ function currentKeys(account) { const { sshDir } = paths(account); let ids; try { ids = fs.readdirSync(sshDir).filter((n) => KEY_ID_RE.test(n)); } catch { return []; } const rank = (id) => { const i = Object.values(KEY_FILES).indexOf(id); return i < 0 ? Object.keys(KEY_FILES).length : i; }; ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); const keys = []; for (const id of ids) { const [, type, name = null] = KEY_ID_RE.exec(id); const file = path.join(sshDir, id); const publicKey = readText(account, `${file}.pub`); if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue; // The private half is only looked at, never read into this process. if (!isOwnFile(account, file)) continue; keys.push({ id, type, name, file, publicKey: publicKey.trim() }); } return keys; } /** The private key files a clone or fetch offers (ed25519 first), maybe none. */ const keyFiles = (account) => currentKeys(account).map((k) => k.file); /** ssh options naming `files` — `-i` each, in order. */ const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join(""); /** `[{id, type, name, publicKey, fingerprint}]` of the account's keys — never a private half. */ function sshKeysInfo(account) { return currentKeys(account).map((k) => ({ id: k.id, type: k.type, name: k.name, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey), })); } /** The account's first key (ed25519 before rsa), or null — what `ssh` meant before two. */ const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null; /** * Make the account's key of `type` (ed25519, the default, or rsa) — the * default one of that type, or with `name` one more beside it (another AWS * account, another GitHub user). An existing key of that id is replaced only * with `replace`; every other key is left alone. */ async function generateSshKey( account, { replace = false, comment, type = "ed25519", name = null } = {} ) { if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa"); const keyName = name == null || name === "" ? null : String(name).trim().toLowerCase(); if (keyName !== null && !KEY_NAME_RE.test(keyName)) { throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32"); } const id = keyIdFor(type, keyName); const keys = currentKeys(account); if (keys.some((k) => k.id === id) && !replace) { throw err("EEXISTS", `the SSH key ${keyName ?? type} already exists — replace it explicitly`); } if (keyName !== null && keys.some((k) => k.name === keyName && k.id !== id)) { throw err("EEXISTS", `a key named ${keyName} already exists`); } if (!keys.some((k) => k.id === id) && keys.length >= MAX_KEYS) { throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`); } const label = String(comment || "agenthub") .replace(/[^\w.@+-]/g, "") .slice(0, 64) || "agenthub"; const { privateKey, publicKey } = type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label); const { sshDir } = paths(account); const file = path.join(sshDir, id); writeText(account, file, privateKey, { secret: true }); writeText(account, `${file}.pub`, publicKey, { secret: false }); // A member's home is AgentHub's to set up: their terminal git uses the key // too, unless they wrote a core.sshCommand of their own. The host's is // its admin's choice (the Settings switch) — once on, it follows the keys. if (account.kind === "member" || (await terminalSshState(account)) === "on") { await setTerminalSsh(account, true).catch(() => {}); } return sshKeysInfo(account).find((k) => k.id === id) ?? null; } /** * Import a key the user already has (`privateKey`: the file's text) as a * NAMED key beside the generated ones — `name`, else `signing`, `signing-2`… * Written exactly like a generated key (0600, as the member), so it serves * clones and pushes too; only its public half is ever returned. The same key * twice is EEXISTS, naming the key that holds it. */ async function importSshKey(account, { privateKey, name = null, comment } = {}) { const parsed = parsePrivateKey(privateKey, comment); const keys = currentKeys(account); const fp = fingerprint(parsed.publicKey); const twin = keys.find((k) => fingerprint(k.publicKey) === fp); if (twin) throw err("EEXISTS", `this key is already here as ${twin.name ?? twin.type}`); let keyName = name == null || name === "" ? null : String(name).trim().toLowerCase(); if (keyName === null) { keyName = "signing"; for (let n = 2; keys.some((k) => k.name === keyName); n++) keyName = `signing-${n}`; } if (!KEY_NAME_RE.test(keyName)) { throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32"); } if (keys.some((k) => k.name === keyName)) throw err("EEXISTS", `a key named ${keyName} already exists`); if (keys.length >= MAX_KEYS) throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`); const id = keyIdFor(parsed.type, keyName); const file = path.join(paths(account).sshDir, id); writeText(account, file, parsed.privateKey, { secret: true }); writeText(account, `${file}.pub`, parsed.publicKey, { secret: false }); if (account.kind === "member" || (await terminalSshState(account)) === "on") { await setTerminalSsh(account, true).catch(() => {}); } return sshKeysInfo(account).find((k) => k.id === id) ?? null; } // ── Commit signing (`~/.gitconfig` gpg.format / user.signingkey) ─────────── /** One global git config value, or "" when unset. */ async function globalGet(account, key) { const { git } = require("./worktree"); return git(homeOf(account), ["config", "--global", "--get", key]).then( (r) => r.stdout.trim(), () => "" ); } /** * Whether the account's commits are signed with one of ITS AgentHub keys: * `{state: "on", key}` (gpg.format ssh, user.signingkey = that key's private * file — ssh-keygen signs with the private file, a `.pub` needs an agent the * dashboard has not got — and commit.gpgsign true), `{state: "off"}`, or * `{state: "foreign", path}` when the user set a signing key of their own * (never touched without `replace`). */ async function signingState(account) { const signingKey = await globalGet(account, "user.signingkey"); if (!signingKey) return { state: "off", key: null, path: null }; const k = currentKeys(account).find((x) => x.file === signingKey); // Not one of our keys — the user's own, or one of ours deleted by hand // (commits then fail): either way show the path, so a key can be picked. if (!k) return { state: "foreign", key: null, path: signingKey }; const on = (await globalGet(account, "gpg.format")) === "ssh" && (await globalGet(account, "commit.gpgsign")) === "true"; return { state: on ? "on" : "off", key: on ? k.id : null, path: null }; } /** * Sign every commit of the account with key `id` (null = stop signing). * Over a signing key the user set themselves only with `replace` * (EFOREIGNSIGN); off removes only our own settings. No value is a secret — * the key's PATH crosses sudo's argv, never its content. */ async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) { const { git } = require("./worktree"); const home = homeOf(account); const state = await signingState(account); const unset = (key) => git(home, ["config", "--global", "--unset", key]).catch(() => { /* already unset */ }); if (id == null) { // The user's own setup is left alone unless they asked to take it out // (`clearForeign`): only the three settings go, never their key file. if (state.state === "foreign" && !clearForeign) return state; if ((await globalGet(account, "user.signingkey")) !== "") { await unset("commit.gpgsign"); await unset("user.signingkey"); await unset("gpg.format"); } return signingState(account); } const k = currentKeys(account).find((x) => x.id === id); if (!k) throw err("ENOKEY", "no such SSH key"); if (state.state === "foreign" && !replace) { throw err( "EFOREIGNSIGN", `your ~/.gitconfig already signs with ${state.path} — confirm to switch to ${k.name ?? k.type}` ); } await git(home, ["config", "--global", "gpg.format", "ssh"]); await git(home, ["config", "--global", "user.signingkey", k.file]); await git(home, ["config", "--global", "commit.gpgsign", "true"]); return signingState(account); } // ── Commit signing per workspace (`includeIf "gitdir:…"`) ────────────────── // // A workspace is signed by a small config file of OURS per workspace // (`lane-.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign), // pulled into the account's `~/.gitconfig` by `includeIf "gitdir:"` for // the workspace's own git dir and for the git dirs of the worktrees its // sessions make (`ah--*`, pty-run.js). Every git that reads the account's // config — the terminal, the Changes panel, an agent — then signs there and // nowhere else; nothing is written into the user's repository. const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/; /** Where the per-workspace files live: ours, beside the account's keys. */ function signingDir(account) { return account.kind === "member" ? path.join(paths(account).base, "signing") : path.join(getClaudeHome(), "agenthub-signing"); } const laneSignFile = (account, laneId) => path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`); /** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */ async function ourIncludes(account) { const { git } = require("./worktree"); const out = await git(homeOf(account), [ "config", "--global", "--null", "--get-regexp", "^includeif\\..*\\.path$", ]).then( (r) => r.stdout, () => "" ); const dir = signingDir(account); const rows = []; // `--null`: "key\nvalue\0" — a git dir or a path may hold spaces. for (const rec of out.split("\0")) { const at = rec.indexOf("\n"); if (at <= 0) continue; const key = rec.slice(0, at); const value = rec.slice(at + 1); if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) { rows.push({ key, value }); } } return rows; } /** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */ async function dropIncludes(account, file) { const { git } = require("./worktree"); for (const r of await ourIncludes(account)) { if (r.value !== file) continue; await git(homeOf(account), [ "config", "--global", "--fixed-value", "--unset-all", r.key, r.value, ]).catch(() => {}); } } /** The key file a workspace file signs with (its `signingkey` line), or null. */ function laneSignKeyFile(account, file) { const text = readText(account, file) || ""; const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text); return m ? m[1].replace(/\\(["\\])/g, "$1") : null; } /** * Which workspaces sign their commits, and with which key: * `[{laneId, key}]` (`key` null when the key file it names is gone). */ async function laneSigningList(account) { const keys = currentKeys(account); const seen = new Map(); for (const r of await ourIncludes(account)) { const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]); if (seen.has(laneId)) continue; const keyFile = laneSignKeyFile(account, r.value); seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null); } return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId); } const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`; /** * Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop * (`id` null). The git dirs come from git itself, run in the workspace's * folder as its account — never from the request. ENOREPO when the folder is * not a git repository. */ async function setLaneSigning(account, { laneId, cwd, id = null }) { const n = Number(laneId); if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace"); const file = laneSignFile(account, n); await dropIncludes(account, file); if (id == null) { removeFile(account, file); return; } const k = currentKeys(account).find((x) => x.id === id); if (!k) throw err("ENOKEY", "no such SSH key"); const { git } = require("./worktree"); const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim()); let gitDir; let commonDir; try { gitDir = await rev(["--absolute-git-dir"]); commonDir = await rev(["--path-format=absolute", "--git-common-dir"]); } catch { throw err("ENOREPO", "this workspace's folder is not a git repository"); } writeText( account, file, `# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` + `[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` + `[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`, { secret: false } ); const add = (pattern) => git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]); await add(gitDir); // The worktrees this workspace's sessions make (`claude --worktree=ah--…`). await add(path.join(commonDir, "worktrees", `ah-${n}-*`)); } /** * Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when * neither is given; false when there was none. A login naming a deleted key * goes back to offering every key; the terminal sshCommand follows the keys * and goes with the last one. */ async function removeSshKey(account, which) { const { type, id } = typeof which === "string" ? { type: which } : which || {}; if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa"); if (id != null && !KEY_ID_RE.test(id)) throw err("EBADKEYNAME", "no such key id"); const gone = currentKeys(account).filter((k) => id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true ); if (!gone.length) return false; // Commits signed with a key about to go would all fail: stop signing first — // account-wide, and in every workspace that signs with it. const signingKey = await globalGet(account, "user.signingkey"); if (signingKey && gone.some((k) => k.file === signingKey)) { await setSigning(account, { id: null }).catch(() => {}); } const goneIds = new Set(gone.map((k) => k.id)); for (const l of await laneSigningList(account)) { if (goneIds.has(l.key)) { await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {}); } } for (const k of gone) { removeFile(account, k.file); removeFile(account, `${k.file}.pub`); } const ids = new Set(gone.map((k) => k.id)); for (const [from, key] of await keyLinks(account)) { if (ids.has(key)) await unlinkKey(account, from); } if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {}); else if ((await terminalSshState(account)) === "on") { await setTerminalSsh(account, true).catch(() => {}); } return true; } // ── HTTPS logins ─────────────────────────────────────────────────────────── function hostKey(protocol, host) { const p = String(protocol || "").toLowerCase(); const h = String(host || "").toLowerCase(); if (p !== "https" || !HOST_RE.test(h)) throw err("EBADHOST", "an https host[:port] is required"); return { protocol: p, host: h }; } /** The member's store, parsed: `[{protocol, host, username, password}]`. */ function readStore(account, { strict = false } = {}) { const text = readText(account, paths(account).store, { strict }) || ""; const rows = []; for (const line of text.split("\n")) { try { const u = new URL(line.trim()); if (u.protocol !== "https:" || !u.password) continue; rows.push({ protocol: "https", host: u.host.toLowerCase(), username: decodeURIComponent(u.username), password: decodeURIComponent(u.password), }); } catch { /* not a credential line */ } } return rows; } function writeStore(account, rows) { const enc = (s) => encodeURIComponent(s); const text = rows .map((r) => `https://${enc(r.username)}:${enc(r.password)}@${r.host}\n`) .join(""); writeText(account, paths(account).store, text, { secret: true }); } function listHttps(account) { if (account.kind === "host") return gitCredentials.list(); return readStore(account).map(({ protocol, host, username }) => ({ protocol, host, username, hasToken: true, })); } function checkCred(username, password) { if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) { throw err("EBADCRED", "username is required"); } if (typeof password !== "string" || !password || /[\r\n]/.test(password)) { throw err("EBADCRED", "password or access token is required"); } } function saveHttps(account, { protocol = "https", host, username, password }) { if (account.kind === "host") return gitCredentials.save({ protocol, host, username, password }); const k = hostKey(protocol, host); checkCred(username, password); const rows = readStore(account, { strict: true }).filter((r) => r.host !== k.host); rows.push({ ...k, username: username.trim(), password }); writeStore(account, rows); } function removeHttps(account, protocol, host) { if (account.kind === "host") return gitCredentials.remove(protocol, host); const k = hostKey(protocol, host); const rows = readStore(account, { strict: true }); const kept = rows.filter((r) => r.host !== k.host); if (kept.length === rows.length) return false; writeStore(account, kept); return true; } // ── Commit author ────────────────────────────────────────────────────────── /** The folder a `git config --global` for this account runs in. */ const homeOf = (account) => (account.kind === "member" ? account.acct.home : os.homedir()); /** * `{name, email}` of the account's global git config (`user.name` / * `user.email`, null when unset). As the member under S3: their own * `~/.gitconfig`, which their panes' commits use too. */ async function getAuthor(account) { const { git } = require("./worktree"); const get = (k) => git(homeOf(account), ["config", "--global", "--get", k]).then( (r) => r.stdout.trim() || null, () => null ); return { name: await get("user.name"), email: await get("user.email") }; } /** The domain of a suggested email when the account has no author yet. */ const DEFAULT_EMAIL_DOMAIN = process.env.AGENTHUB_EMAIL_DOMAIN || "smartgift.vn"; /** `@`, or null without a usable name. */ function defaultEmail(username) { const local = String(username || "").replace(/[^\w.+-]/g, ""); return local ? `${local}@${DEFAULT_EMAIL_DOMAIN}` : null; } /** * Set the account's commit author. Neither is a secret, so they may cross * sudo's argv; each is one line, a name up to 100 and an email up to 254 * characters, the email `local@domain`. */ async function setAuthor(account, { name, email }) { const n = typeof name === "string" ? name.trim() : ""; const e = typeof email === "string" ? email.trim() : ""; // eslint-disable-next-line no-control-regex const ctl = /[\x00-\x1f\x7f]/; if (!n || n.length > 100 || ctl.test(n)) throw err("EBADAUTHOR", "a name of 1–100 characters"); if (e.length > 254 || ctl.test(e) || !/^[^\s@]+@[^\s@]+$/.test(e)) { throw err("EBADAUTHOR", "an email like name@example.com"); } const { git } = require("./worktree"); await git(homeOf(account), ["config", "--global", "user.name", n]); await git(homeOf(account), ["config", "--global", "user.email", e]); return getAuthor(account); } // ── The key in the account's own terminal (`~/.gitconfig` core.sshCommand) ── /** * The `core.sshCommand` that makes a plain `git` in the account's terminal * use the AgentHub key (issue #82: a clone typed in a pane offered only * `~/.ssh/id_*` and was refused). Both key files are named, so swapping the * key's type needs no rewrite; ssh skips a missing `IdentityFile` silently. * Naming one drops ssh's default list, so the defaults are named again after * ours — a key the user set up in `~/.ssh/` keeps working. */ function terminalSshCommand(account) { const { sshDir } = paths(account); const files = new Set([ ...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...currentKeys(account).map((k) => k.file), ]); const ours = [...files].map((f) => `-o IdentityFile=${shq(f)}`); const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`); return ["ssh", ...ours, ...defaults].join(" "); } /** * Whether `cmd` is a terminal sshCommand WE wrote — of our shape, naming * key files in this account's AgentHub folder (any set of them: it follows * the keys as they are made and deleted) and nothing else but the defaults. */ function isOurTerminal(account, cmd) { const m = /^ssh((?: -o IdentityFile=(?:'[^']*'|~\/\.ssh\/id_[a-z0-9]+))+)$/.exec(cmd || ""); if (!m) return false; const files = [...m[1].matchAll(/IdentityFile='([^']*)'/g)].map((x) => x[1]); const { sshDir } = paths(account); return files.length > 0 && files.every((f) => path.dirname(f) === sshDir); } /** The account's global `core.sshCommand`, or "" when unset. */ async function globalSshCommand(account) { const { git } = require("./worktree"); return git(homeOf(account), ["config", "--global", "--get", "core.sshCommand"]).then( (r) => r.stdout.trim(), () => "" ); } /** * Whether the account's terminal git uses the AgentHub key: `on` (our * sshCommand), `off` (none set) or `foreign` (one the user wrote — never * touched). */ async function terminalSshState(account) { const cmd = await globalSshCommand(account); if (!cmd) return "off"; return isOurTerminal(account, cmd) ? "on" : "foreign"; } /** * Turn the AgentHub key on/off for the account's terminal git. On refuses * over a sshCommand the user wrote (`EFOREIGNSSH`); off removes only ours. * Neither value is a secret, so it may cross sudo's argv. */ async function setTerminalSsh(account, on) { const { git } = require("./worktree"); const state = await terminalSshState(account); if (on) { if (state === "foreign") { throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is"); } // Off, or ours naming another set of keys than the account holds now. if (state === "off" || (await globalSshCommand(account)) !== terminalSshCommand(account)) { await git(homeOf(account), [ "config", "--global", "core.sshCommand", terminalSshCommand(account), ]); } } else if (state === "on") { await git(homeOf(account), ["config", "--global", "--unset", "core.sshCommand"]); } return terminalSshState(account); } // ── SSH login name per host (`~/.gitconfig` url.<…>.insteadOf) ───────────── // AWS CodeCommit logs an SSH key in by its IAM key ID (`APKA…`) as the user // name, so `ssh://git-codecommit…/v1/repos/x` (no user) is refused. One // `url."ssh://@/".insteadOf "ssh:///"` per host makes the // plain URL work for every git of the account — a clone typed in a pane, the // Clone dialog's and the git bar's — without touching `~/.ssh/config`. The // user name is no secret (an ID, not a key), so it may cross sudo's argv. const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/; // An alias has no dot, so it can never shadow a real host name. const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; const insteadOfBase = (host, user) => `ssh://${user}@${host}/`; // AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY // region of that AWS account. Saved with `allRegions`, the default login is // written for each region here, so a plain `ssh://git-codecommit.…/` URL // logs in — in a pane's terminal too — and the user never picks a region. const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/; const CODECOMMIT_REGIONS = [ "us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1", "sa-east-1", "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1", "eu-south-1", "il-central-1", "me-south-1", "af-south-1", "ap-east-1", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", ]; const codecommitHost = (region) => `git-codecommit.${region}.amazonaws.com`; /** `Map`: the key each login names (`agenthub..sshKey`). */ async function keyLinks(account) { const { git } = require("./worktree"); const out = await git(homeOf(account), [ "config", "--global", "--get-regexp", "^agenthub\\..*\\.sshkey$", ]).then( (r) => r.stdout, () => "" ); const links = new Map(); for (const line of String(out).split("\n")) { const m = /^agenthub\.(.+)\.sshkey (\S+)$/.exec(line); if (m && KEY_ID_RE.test(m[2])) links.set(m[1], m[2]); } return links; } async function unlinkKey(account, from) { const { git } = require("./worktree"); await git(homeOf(account), [ "config", "--global", "--unset-all", `agenthub.${from}.sshKey`, ]).catch(() => {}); } /** * `[{host, user, alias, key}]` — only the rules of exactly our shape; any * other insteadOf is the user's. `alias` is null for the host's own rule * (`ssh:///` logs in as `user`), else the name standing for that * user@host (`ssh:///`); `key` is the key id the login uses, or null * for every key. */ async function listSshUsers(account) { const { git } = require("./worktree"); const out = await git(homeOf(account), [ "config", "--global", "--get-regexp", "^url\\..*\\.insteadof$", ]).then( (r) => r.stdout, () => "" ); const links = await keyLinks(account); const rows = []; for (const line of out.split("\n")) { const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line); if ( m && (m[2] === m[3] || ALIAS_RE.test(m[3])) && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2]) ) { rows.push({ host: m[2], user: m[1], alias: m[2] === m[3] ? null : m[3], key: links.get(m[3]) ?? null, }); } } return rows; } /** * Drop our rule for `from` — a host's own rule, or an alias's — and the key * it names; false when there was none. */ async function removeSshUser(account, from, { allRegions = false, user = null } = {}) { const { git } = require("./worktree"); const f = String(from || "").toLowerCase(); // `allRegions` (from a CodeCommit host): every region's own rule — of // `user` only when given — never an alias (a named key's login). const rows = (await listSshUsers(account)).filter((r) => allRegions && CODECOMMIT_RE.test(f) ? r.alias === null && CODECOMMIT_RE.test(r.host) && (!user || r.user === user) : (r.alias ?? r.host) === f ); for (const r of rows) { await git(homeOf(account), [ "config", "--global", "--fixed-value", "--unset-all", `url.${insteadOfBase(r.host, r.user)}.insteadOf`, `ssh://${r.alias ?? r.host}/`, ]); await unlinkKey(account, r.alias ?? r.host); } return rows.length > 0; } /** * Log in to `host` over SSH as `user` — for `ssh:///` itself, or, with * `alias`, for `ssh:///` (one more account on the same host) — with * the key `key` only, or every key when it is null. Replaces our earlier rule * for that host or alias. */ async function setSshUser( account, { host, user, alias = null, key = null, allRegions = false } = {} ) { const h = String(host || "") .trim() .toLowerCase(); const u = String(user || "").trim(); const a = alias == null || alias === "" ? null : String(alias).trim().toLowerCase(); if (!HOST_RE.test(h)) { throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com"); } if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -"); if (a !== null && !ALIAS_RE.test(a)) { throw err("EBADALIAS", "an account name of lowercase letters, digits and - (no dot)"); } if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) { throw err("ENOKEY", "no such SSH key"); } const { git } = require("./worktree"); if (allRegions === true) { if (a !== null || !CODECOMMIT_RE.test(h)) { throw err("EBADHOST", "every region is for AWS CodeCommit's own login, without an alias"); } // One login for the whole AWS account: every region's rule is replaced. await removeSshUser(account, h, { allRegions: true }); for (const region of CODECOMMIT_REGIONS) { const rh = codecommitHost(region); await git(homeOf(account), [ "config", "--global", "--add", `url.${insteadOfBase(rh, u)}.insteadOf`, `ssh://${rh}/`, ]); if (key) await git(homeOf(account), ["config", "--global", `agenthub.${rh}.sshKey`, key]); } return listSshUsers(account); } const from = a ?? h; await removeSshUser(account, from); await git(homeOf(account), [ "config", "--global", "--add", `url.${insteadOfBase(h, u)}.insteadOf`, `ssh://${from}/`, ]); if (key) await git(homeOf(account), ["config", "--global", `agenthub.${from}.sshKey`, key]); return listSshUsers(account); } /** * The key file a clone of `url` must use: the one its login names, when the * URL is `ssh:///…` with no user of its own (the only shape * our insteadOf rules rewrite). Null: offer every key. */ async function linkedKeyFile(account, url) { if (!url.startsWith("ssh://")) return null; let from; try { const u = new URL(url); if (u.username) return null; from = u.host.toLowerCase(); } catch { return null; } const row = (await listSshUsers(account)).find((r) => (r.alias ?? r.host) === from && r.key); return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null; } // A CodeCommit URL with no user is tried with every CodeCommit login saved // (the region's own first, then other regions' and every named key's — an // alias row's user is as valid), each with its own key when it names one: // a login saved for one region, or a second AWS account's key, clones from // any region without the user rewriting the URL. The first the remote // accepts wins (`cloneNow`). /** `[{url, keyFile}]`: `url` with each borrowable CodeCommit user in it. */ async function borrowedLogins(account, url) { if (!url.startsWith("ssh://")) return []; let u; try { u = new URL(url); } catch { return []; } const host = u.host.toLowerCase(); if (u.username || !CODECOMMIT_RE.test(host)) return []; const all = await listSshUsers(account); // The region's own login first: it is the likeliest to be the right one. const rows = [ ...all.filter((r) => r.alias === null && r.host === host), ...all.filter((r) => !(r.alias === null && r.host === host)), ]; const keys = currentKeys(account); const seen = new Set(); const out = []; for (const r of rows) { if (!CODECOMMIT_RE.test(r.host)) continue; const keyFile = r.key ? (keys.find((k) => k.id === r.key)?.file ?? null) : null; if (r.key && !keyFile) continue; const id = `${r.user}\0${keyFile ?? ""}`; if (seen.has(id)) continue; seen.add(id); out.push({ url: `ssh://${r.user}@${host}${u.pathname}`, keyFile }); } // One login in all, and it is the region's own: git's insteadOf applies it. if (out.length === 1 && rows[0]?.alias === null && rows[0]?.host === host) return []; return out; } // ── Network commands in an existing repo (git bar fetch/pull/push) ───────── /** * `-c core.sshCommand=…` for a repo with an ssh remote and no sshCommand of * its own: the account's AgentHub key when there is one (the default keys * are still tried — no IdentitiesOnly here), and never a prompt — no terminal * answers one. A repo that sets its own sshCommand keeps it. */ async function sshConfigFor(dir) { const { git } = require("./worktree"); const remotes = await git(dir, ["remote", "-v"]).then( (r) => r.stdout, () => "" ); // Anything that is not http(s) or a local path is ssh to git. const ssh = remotes .split("\n") .some((l) => /\t(ssh:\/\/|[^\s/:]+@[^\s:]+:)/i.test(l) && !/\t(https?|file):\/\//i.test(l)); if (!ssh) return []; const own = await git(dir, ["config", "--get", "core.sshCommand"]).then( (r) => r.stdout.trim(), () => "" ); let account; try { account = accountForDir(dir); } catch { return []; } // Our own terminal sshCommand (global) is not the repo's choice: it has no // BatchMode, so it would leave a dashboard fetch waiting on a prompt. if (own && !isOurTerminal(account, own)) return []; return [ "-c", `core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`, ]; } /** * A member's (S3) https login for a fetch/pull/push: their own store, given * to git as the only helper while it holds one of the repo's hosts. `once` * (typed in the git bar, for one of `hosts` only) goes INTO that store * through `write-as`'s stdin — the only way a secret reaches git running as * them — and `finish(ok)` takes it out again unless it worked and * `remember` was set. `stored(host)`: whether the store holds that host. */ function memberHttpsConfig(account, hosts, once) { const before = readStore(account, { strict: true }); let finish = () => {}; if (once) { const k = hostKey(once.protocol || "https", once.host); if (!hosts.some((h) => h.host === k.host)) { throw err("EBADHOST", `${k.host} is not a remote of this repository`); } checkCred(once.username, once.password); saveHttps(account, { host: k.host, username: once.username, password: once.password }); finish = (ok) => { if (ok && once.remember) return; try { writeStore(account, before); } catch { /* best effort: the store is the member's own */ } }; } const row = (host) => { const r = before.find((x) => x.host === String(host).toLowerCase()); return r ? { username: r.username } : null; }; const args = once || hosts.some((h) => h.protocol === "https" && row(h.host)) ? [ "-c", "credential.helper=", "-c", `credential.helper=store --file=${shq(paths(account).store)}`, ] : []; return { args, finish, stored: (_protocol, host) => row(host) }; } // ── Clone ────────────────────────────────────────────────────────────────── /** `{scheme: "ssh"|"https", host}` of a URL `validateRemoteUrl` accepted. */ function remoteOf(url) { if (url.startsWith("https://")) return { scheme: "https", host: new URL(url).host.toLowerCase() }; if (url.startsWith("ssh://")) return { scheme: "ssh", host: new URL(url).host.toLowerCase() }; return { scheme: "ssh", host: url.slice(url.indexOf("@") + 1, url.indexOf(":")).toLowerCase() }; } /** The folder name a clone of `url` gets by default — git's own rule, roughly. */ function defaultName(url) { const tail = url .replace(/[/:]+$/, "") .split(/[/:]/) .pop() || ""; return tail.replace(/\.git$/i, ""); } /** git's wording for "I needed an https login and did not get a good one". */ function httpsFailure(stderr) { return /could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test( String(stderr || "") ); } /** The forge's "no such repository" — over ssh it ALSO says "Could not read * from remote repository", which is not a refused key. */ function notFound(stderr) { return /Repository not found|does not appear to be a git repository|repository '.*' not found|project you were looking for could not be found/i.test( String(stderr || "") ); } /** git's wording for "the SSH login was refused or the host is unknown". */ function sshFailure(stderr) { return /Permission denied \(publickey|Host key verification failed|Could not read from remote repository/i.test( String(stderr || "") ); } /** * The error a failed `git clone` of `url` answers, from git's stderr: * `ECREDENTIALS` (with `{protocol, host}`), `ENOREPO`, `ESSHAUTH`, else * `ECLONE` with git's own words. Shared by the held-open clone and a * background one finished by a later server (clone-jobs.js). */ function cloneFailure(url, stderr, fallback = "git clone failed") { const remote = remoteOf(url); if (remote.scheme === "https" && httpsFailure(stderr)) { return Object.assign(err("ECREDENTIALS", "this repository needs a login"), { protocol: "https", host: remote.host, }); } if (notFound(stderr)) { return err("ENOREPO", `no repository at ${url} (or this login cannot see it)`); } if (remote.scheme === "ssh" && sshFailure(stderr)) { return Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host }); } return err( "ECLONE", (require("./clone-queue").stripProgress(stderr) || fallback).trim().slice(-800) ); } /** * Clone `url` into `/` as the account owning `parent`, with * that account's own identity. `once` is a login typed for this clone * (`{username, password, remember}`) — https only, for this URL's host only. * `partial` makes it a blobless clone (`--filter=blob:none`): every commit * and tree, but a file's contents only when a checkout first needs them — * far less to download and unpack for a big repo, at the cost of a fetch * from the remote when an old revision is checked out later. * Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}` * attached) when git wanted a login it did not get, `ESSHAUTH` when the key * was refused, else the git error. */ function cloneRepo({ job = null, ...opts }) { // Waits for a free clone slot (clone-queue.js) before anything — a typed // login sits in a member's store only while its own clone runs, and the // timeout counts from there. `job` (clone-queue `track`) gets the queue // position and git's progress. const queue = require("./clone-queue"); return queue.withCloneSlot( () => cloneNow(opts, queue.threads(), (chunk) => queue.report(job, chunk)), job ); } // `signal` (a background clone's cancel, clone-jobs.js) SIGTERMs whatever git // call is running: a borrowed-login probe or the clone itself. async function cloneNow( { url, parent, name, once = null, partial = false, signal = null, launch = null }, threads, onProgress ) { const { git } = require("./worktree"); const account = accountForDir(parent); const remote = remoteOf(url); // index-pack's delta threads: this clone's share of the cores, not all of // them (clone-queue.js) — and as many checkout workers: a tree of many // small files is written one file at a time otherwise (~4x slower on 4 // cores). Not persisted — a later fetch or checkout keeps git's default. const pre = ["-c", `pack.threads=${threads}`, "-c", `checkout.workers=${threads}`]; const persist = []; const env = {}; let restore = null; if (remote.scheme === "ssh") { if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key"); // A login naming its key (another AWS account, a second GitHub user) // offers that key ONLY — another key could log in as the wrong account. let linked = await linkedKeyFile(account, url); const sshFor = (file) => { const k = file ? [file] : keyFiles(account); const b = k.length ? `ssh${identityArgs(k)} -o IdentitiesOnly=yes` : "ssh"; // BatchMode: no terminal to answer a prompt; accept-new: first contact // records the host key, a CHANGED one still fails. return `core.sshCommand=${b} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`; }; // A CodeCommit region with no login of its own: the first borrowed login // the remote accepts (one is taken as is). The clone's URL then names // that user, so the repo's `origin` keeps logging in the same way. const borrowed = await borrowedLogins(account, url); for (const b of borrowed) { const ok = borrowed.length === 1 || (await git(parent, ["-c", sshFor(b.keyFile), "ls-remote", "--", b.url, "HEAD"], { timeout: 60_000, signal, }).then( () => true, () => false )); if (ok) { url = b.url; linked = b.keyFile; break; } } const keys = linked ? [linked] : keyFiles(account); pre.push("-c", sshFor(linked)); if (keys.length) { // Persisted by FILE name — both default types plus every named key: // a default key made later is offered too (ssh skips a missing // IdentityFile silently, where `-i` would warn). A linked key alone. const { sshDir } = paths(account); const files = linked ? [linked] : [...new Set([...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...keys])]; const all = files.map((f) => ` -o IdentityFile=${shq(f)}`).join(""); persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`); } } else if (account.kind === "member") { const k = hostKey("https", remote.host); const before = readStore(account, { strict: true }); if (once) { checkCred(once.username, once.password); saveHttps(account, { host: k.host, username: once.username, password: once.password }); restore = () => writeStore(account, before); } if (once || before.some((r) => r.host === k.host)) { const helper = `store --file=${shq(paths(account).store)}`; pre.push("-c", "credential.helper=", "-c", `credential.helper=${helper}`); if (!once || once.remember) persist.push("-c", `credential.helper=${helper}`); } } else { const k = hostKey("https", remote.host); if (once) { checkCred(once.username, once.password); env.AGENTHUB_GIT_ONCE = JSON.stringify({ ...k, username: once.username, password: once.password, }); } if (once || gitCredentials.find(k.protocol, k.host)) { env.AGENTHUB_GIT_CRED_FILE = gitCredentials.credentialsFile(); pre.push( "-c", "credential.helper=", "-c", `credential.helper=!${shq(process.execPath)} ${shq(HELPER)}` ); } } let ok = false; try { if (signal?.aborted) throw err("ECANCELLED", "Clone cancelled"); const filter = partial ? ["--filter=blob:none"] : []; // --progress: git prints it to a pipe too, for the UI's progress line. // `launch` (a background clone, clone-jobs.js) runs this same command in // a detached runner instead, and rejects like git() does. const args = [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name]; const gitOpts = { timeout: CLONE_TIMEOUT_MS, env, background: true, onStderr: onProgress, signal, }; await (launch ? launch(parent, args, gitOpts) : git(parent, args, gitOpts)); ok = true; } catch (e) { // SIGTERMed by a cancel: git (sudo forwards it) removes its own half-made folder. if (signal?.aborted) throw err("ECANCELLED", "Clone cancelled"); throw cloneFailure(url, e.git?.stderr, e.message); } finally { // A login typed without "remember" — or one that did not work — leaves // the member's store as it was. if (restore && (!ok || !once.remember)) { try { restore(); } catch { /* best effort: the store is the member's own */ } } } if (ok && once?.remember && account.kind === "host") { gitCredentials.save({ protocol: "https", host: remote.host, ...once }); } return { dir: path.join(parent, name), account: account.kind }; } module.exports = { accountForDir, accountForRequest, makeKeyPair, makeRsaKeyPair, fingerprint, sshKeyInfo, sshKeysInfo, generateSshKey, importSshKey, parsePrivateKey, signingState, laneSigningList, setLaneSigning, setSigning, removeSshKey, terminalSshCommand, terminalSshState, setTerminalSsh, listSshUsers, setSshUser, removeSshUser, CODECOMMIT_REGIONS, listHttps, saveHttps, removeHttps, remoteOf, defaultName, cloneRepo, cloneFailure, getAuthor, defaultEmail, setAuthor, sshConfigFor, memberHttpsConfig, CLONE_TIMEOUT_MS, };