/** * @file AgentHub's own git credentials — what a dashboard fetch/pull/push * authenticates with when the machine's git has nothing (issue #45). The * dashboard runs git with no terminal, so without this a remote needing a * login fails with "could not read Username … terminal prompts disabled". * * One JSON file under `/agenthub-git-credentials/` (dir 0700, * file 0600, written through a temp file), one record per protocol + host, * shared by everyone using this AgentHub. Like the AI Proxy store, **no route * ever returns a password** — `list` reports `hasToken`. * * `gitConfigFor` is how a credential reaches git: `-c` args that make OUR * helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only * ours, because a stale token in the machine's helper would win over the one * just typed, and git's post-success `store` would copy a use-once token into * the OS user's own helper. The secret itself never reaches argv. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/; const PROTOCOLS = new Set(["https", "http"]); const HELPER = path.join(__dirname, "git-credential-helper.js"); function err(code, message) { return Object.assign(new Error(message), { code }); } function credentialsFile() { return path.join(getClaudeHome(), "agenthub-git-credentials", "credentials.json"); } /** `{protocol, host}` normalised, or EBADHOST — a host is also a lookup key. */ function key(protocol, host) { const p = String(protocol || "").toLowerCase(); const h = String(host || "").toLowerCase(); if (!PROTOCOLS.has(p) || !HOST_RE.test(h)) { throw err("EBADHOST", "protocol must be http(s) and host a plain host[:port]"); } return { protocol: p, host: h }; } function readAll() { try { const rows = JSON.parse(fs.readFileSync(credentialsFile(), "utf8")); return Array.isArray(rows) ? rows : []; } catch { return []; } } function writeAll(rows) { const file = credentialsFile(); fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.agenthub-tmp`; fs.writeFileSync(tmp, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(tmp, file); } function list() { return readAll().map(({ protocol, host, username, savedAt }) => ({ protocol, host, username, savedAt, hasToken: true, })); } function find(protocol, host) { const k = key(protocol, host); return readAll().find((r) => r.protocol === k.protocol && r.host === k.host) || null; } function save({ protocol, host, username, password }) { const k = key(protocol, host); 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"); } const rows = readAll().filter((r) => !(r.protocol === k.protocol && r.host === k.host)); rows.push({ ...k, username: username.trim(), password, savedAt: new Date().toISOString() }); writeAll(rows); } function remove(protocol, host) { const k = key(protocol, host); const rows = readAll(); const kept = rows.filter((r) => !(r.protocol === k.protocol && r.host === k.host)); if (kept.length === rows.length) return false; writeAll(kept); return true; } /** * git's wording for "I needed a login and did not get one (or it was wrong)", * and the URL it names. null for every other failure. */ function authFailure(stderr) { const text = String(stderr || ""); if ( !/could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test( text ) ) { return null; } const m = text.match(/'(https?):\/\/(?:[^@'/]*@)?([^/'\s]+)/i); if (!m) return null; try { return key(m[1], m[2]); } catch { return null; } } /** The http(s) hosts a repo's remotes point at. */ async function remoteHosts(dir) { // Required lazily: worktree.js is heavy and only this path needs it. const { git } = require("./worktree"); const out = await git(dir, ["remote", "-v"]).then( (r) => r.stdout, () => "" ); const hosts = new Map(); // A username in the URL (https://vi@host/…) pre-fills the login form; a // password in it (user:pass@) is never read back out. for (const m of out.matchAll(/\t(https?):\/\/(?:([^@\s/:]*)(?::[^@\s/]*)?@)?([^/\s]+)/gi)) { try { const k = key(m[1], m[3]); hosts.set(`${k.protocol}://${k.host}`, { ...k, username: m[2] ? decodeURIComponent(m[2]) : null, }); } catch { /* not a host we can hold a credential for */ } } return [...hosts.values()]; } const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`; /** * The `-c` args and env a network git command runs with. `once` is a * credential from the request, accepted only for one of the repo's own * remote hosts — a key typed for one server is never sent to another. */ async function gitConfigFor(dir, once) { const hosts = await remoteHosts(dir); const env = {}; if (once) { const k = key(once.protocol, once.host); if (!hosts.some((h) => h.protocol === k.protocol && h.host === k.host)) { throw err("EBADHOST", `${k.host} is not a remote of this repository`); } env.AGENTHUB_GIT_ONCE = JSON.stringify({ ...k, username: String(once.username || ""), password: String(once.password || ""), }); } else if (!hosts.some((h) => find(h.protocol, h.host))) { return { args: [], env: {} }; } // ponytail: while AgentHub answers for one host, a remote on ANOTHER host // gets no machine helper either (see the file header); chain the machine's // helpers for unknown hosts if mixed-host `fetch --all` ever matters. env.AGENTHUB_GIT_CRED_FILE = credentialsFile(); return { args: [ "-c", "credential.helper=", "-c", `credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`, ], env, }; } module.exports = { credentialsFile, list, find, save, remove, authFailure, remoteHosts, gitConfigFor, };