758609775f
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable one showing its error); a folder opens that user's Trash (path bar Trash › user, Up back to the folders). An admin may restore/purge in another user's Trash (audited); purging asks first. The merged "All users" list is gone from the UI. - A home's Trash is its user's whole recycle bin (home store + host store, items tagged `store`). - `agenthub server setup` installs one Linux user per account on Linux by default (`--no-system` opts out); install-system messages drop `sudo`. - Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
816 lines
31 KiB
JavaScript
816 lines
31 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 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") };
|
||
}
|
||
|
||
/** 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 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,
|
||
defaultEmail,
|
||
setAuthor,
|
||
sshConfigFor,
|
||
memberHttpsConfig,
|
||
CLONE_TIMEOUT_MS,
|
||
};
|