Files
AgentHub/server/lib/git-identity.js
T
nntrivi2001 b058179177 feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
2026-10-15 16:42:47 +07:00

985 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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
* `<CLAUDE_HOME>/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=<the member's store>` — 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_<type>_<name>`), and a login may name the key it uses
* (`agenthub.<from>.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ĩ <vinnt@smartgift.vn>
*/
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`). 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");
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(/=+$/, "")}`;
}
/**
* `[{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;
}
/**
* 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;
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";
/** `<username>@<DEFAULT_EMAIL_DOMAIN>`, 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://<user>@<host>/".insteadOf "ssh://<host>/"` 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}/`;
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.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://<host>/` logs in as `user`), else the name standing for that
* user@host (`ssh://<alias>/`); `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) {
const { git } = require("./worktree");
const f = String(from || "").toLowerCase();
const rows = (await listSshUsers(account)).filter((r) => (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://${f}/`,
]);
}
if (rows.length) await unlinkKey(account, f);
return rows.length > 0;
}
/**
* Log in to `host` over SSH as `user` — for `ssh://<host>/` itself, or, with
* `alias`, for `ssh://<alias>/` (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 } = {}) {
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 from = a ?? h;
await removeSshUser(account, from);
const { git } = require("./worktree");
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://<host or alias>/…` 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;
}
// ── 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 || "")
);
}
/**
* Clone `url` into `<parent>/<name>` 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");
// 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.
const linked = await linkedKeyFile(account, url);
const keys = linked ? [linked] : keyFiles(account);
const base = keys.length ? `ssh${identityArgs(keys)} -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 (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 {
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,
sshKeysInfo,
generateSshKey,
removeSshKey,
terminalSshCommand,
terminalSshState,
setTerminalSsh,
listSshUsers,
setSshUser,
removeSshUser,
listHttps,
saveHttps,
removeHttps,
remoteOf,
defaultName,
cloneRepo,
getAuthor,
defaultEmail,
setAuthor,
sshConfigFor,
memberHttpsConfig,
CLONE_TIMEOUT_MS,
};