Files
AgentHub/server/lib/git-identity.js
T
nntrivi2001 276cead826 feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work
AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a
plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x`
was refused as the Linux account name. The account can now save an SSH
user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` —
written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its
own ~/.gitconfig (as the member under S3), so the plain URL works in a
pane, in Clone and in the git bar. Only rules of exactly that shape are
listed or removed; any other insteadOf is the user's and left alone.

Settings → Git → Your git identity now shows the SSH keys one section
per kind of host, each with only its own numbered steps: GitHub · GitLab
· Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in
IAM → paste the key ID and pick the region, then a "Ready" clone line),
plus a collapsed "Other SSH hosts (advanced)". The shared key-type
picker is gone: each section has its own Generate/Replace/Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:31:30 +07:00

806 lines
30 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 a per-host SSH login name (`setSshUser`: AWS CodeCommit
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
* @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" };
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, publicKey}]` of the account's keys, ed25519 first. */
function currentKeys(account) {
const { sshDir } = paths(account);
const keys = [];
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;
keys.push({ type, 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("");
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
function sshKeysInfo(account) {
return currentKeys(account).map((k) => ({
type: k.type,
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); an
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
* other type is left alone — a host refusing one type keeps the other.
*/
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
if (currentKeys(account).some((k) => k.type === type) && !replace) {
throw err("EEXISTS", `an ${type} 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 });
// 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).
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
}
/**
* Delete the account's key of `type`, or every key when `type` is omitted;
* false when there was none. The terminal sshCommand goes with the last key.
*/
async function removeSshKey(account, type) {
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
if (!gone.length) return false;
for (const k of gone) {
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
}
if (!currentKeys(account).length) await setTerminalSsh(account, false).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") };
}
/**
* 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 ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
return ["ssh", ...ours, ...defaults].join(" ");
}
/** 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 cmd === terminalSshCommand(account) ? "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");
}
if (state === "off") {
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}$/;
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
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 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] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
rows.push({ host: m[2], user: m[1] });
}
}
return rows;
}
/** Drop our rule for `host` (whatever its user); false when there was none. */
async function removeSshUser(account, host) {
const { git } = require("./worktree");
const h = String(host || "").toLowerCase();
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
for (const r of rows) {
await git(homeOf(account), [
"config",
"--global",
"--fixed-value",
"--unset-all",
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
`ssh://${r.host}/`,
]);
}
return rows.length > 0;
}
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
async function setSshUser(account, { host, user } = {}) {
const h = String(host || "")
.trim()
.toLowerCase();
const u = String(user || "").trim();
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, . _ -");
await removeSshUser(account, h);
const { git } = require("./worktree");
await git(homeOf(account), [
"config",
"--global",
"--add",
`url.${insteadOfBase(h, u)}.insteadOf`,
`ssh://${h}/`,
]);
return listSshUsers(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(),
() => ""
);
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 && own !== terminalSshCommand(account)) 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");
const keys = 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 types: a key made later is offered too
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
const { sshDir } = paths(account);
const all = Object.values(KEY_FILES)
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, 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,
setAuthor,
sshConfigFor,
memberHttpsConfig,
CLONE_TIMEOUT_MS,
};