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