/** * @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` 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. * @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; past this it is killed. */ const CLONE_TIMEOUT_MS = 10 * 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`). One key per account at a time, in the file of its type. */ const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" }; const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" }; 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"); 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`, }; } /** `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(/=+$/, "")}`; } /** `{type, file}` of the account's key (ed25519 first), or null. */ function currentKey(account) { const { sshDir } = paths(account); for (const type of Object.keys(KEY_FILES)) { const file = path.join(sshDir, KEY_FILES[type]); 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; return { type, file, publicKey: publicKey.trim() }; } return null; } /** The private key file a clone or fetch uses, or null. */ const keyFile = (account) => currentKey(account)?.file ?? null; /** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */ function sshKeyInfo(account) { const k = currentKey(account); return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null; } /** * Make the account's key (`type`: ed25519, the default, or rsa); an existing * one is replaced only with `replace` — and a key of the other type goes, so * there is one key per account. */ async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) { if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa"); if (sshKeyInfo(account) && !replace) { throw err("EEXISTS", "an SSH key already exists — replace it explicitly"); } 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, KEY_FILES[type]); writeText(account, file, privateKey, { secret: true }); writeText(account, `${file}.pub`, publicKey, { secret: false }); for (const [other, name] of Object.entries(KEY_FILES)) { if (other === type) continue; const f = path.join(sshDir, name); if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) { removeFile(account, f); removeFile(account, `${f}.pub`); } } return sshKeyInfo(account); } function removeSshKey(account) { const k = currentKey(account); if (!k) return false; removeFile(account, k.file); removeFile(account, `${k.file}.pub`); 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") }; } /** * 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); } // ── 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(), () => "" ); if (own) return []; let account; try { account = accountForDir(dir); } catch { return []; } const key = keyFile(account); return [ "-c", `core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -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 || "") ); } /** * 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. * 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. */ async function cloneRepo({ url, parent, name, once = null }) { const { git } = require("./worktree"); const account = accountForDir(parent); const remote = remoteOf(url); const pre = []; 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"); const key = keyFile(account); const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh"; // BatchMode: no terminal to answer a prompt; accept-new: first contact // records the host key, a CHANGED one still fails. pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`); if (key) persist.push("-c", `core.sshCommand=${base}`); } 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 { await git(parent, [...pre, "clone", ...persist, "--", url, name], { timeout: CLONE_TIMEOUT_MS, env, }); ok = true; } catch (e) { const stderr = e.git?.stderr; if (remote.scheme === "https" && httpsFailure(stderr)) { throw Object.assign(err("ECREDENTIALS", "this repository needs a login"), { protocol: "https", host: remote.host, }); } if (notFound(stderr)) { throw err("ENOREPO", `no repository at ${url} (or this login cannot see it)`); } if (remote.scheme === "ssh" && sshFailure(stderr)) { throw Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host }); } throw err( "ECLONE", String(stderr || e.message) .trim() .slice(-800) ); } 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, generateSshKey, removeSshKey, listHttps, saveHttps, removeHttps, remoteOf, defaultName, cloneRepo, getAuthor, setAuthor, sshConfigFor, memberHttpsConfig, CLONE_TIMEOUT_MS, };