Files
AgentHub/server/lib/git-identity.js
T
nntrivi2001 22de5009ff feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:06:54 +07:00

634 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file A user's OWN git identity — the SSH key and the HTTPS logins a clone
* (and every later fetch/pull/push in the cloned repo) authenticates with —
* and the clone itself (`cloneRepo`, behind `POST /api/lanes/clone`).
*
* Whose identity is decided by an ACCOUNT, never by the request:
* - a member (S3, `run-as.js`): everything lives in their own home, under
* `~/.config/agenthub/` — `ssh/id_ed25519` 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.
* @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`). One key per account at a time, in the file of its type.
*/
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}` of the account's key (ed25519 first), or null. */
function currentKey(account) {
const { sshDir } = paths(account);
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;
return { type, file, publicKey: publicKey.trim() };
}
return null;
}
/** The private key file a clone or fetch uses, or null. */
const keyFile = (account) => currentKey(account)?.file ?? null;
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const k = currentKey(account);
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
}
/**
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
* one is replaced only with `replace` — and a key of the other type goes, so
* there is one key per account.
*/
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
if (sshKeyInfo(account) && !replace) {
throw err("EEXISTS", "an 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 });
for (const [other, name] of Object.entries(KEY_FILES)) {
if (other === type) continue;
const f = path.join(sshDir, name);
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
removeFile(account, f);
removeFile(account, `${f}.pub`);
}
}
return sshKeyInfo(account);
}
function removeSshKey(account) {
const k = currentKey(account);
if (!k) return false;
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
return true;
}
// ── HTTPS logins ───────────────────────────────────────────────────────────
function hostKey(protocol, host) {
const p = String(protocol || "").toLowerCase();
const h = String(host || "").toLowerCase();
if (p !== "https" || !HOST_RE.test(h)) throw err("EBADHOST", "an https host[:port] is required");
return { protocol: p, host: h };
}
/** The member's store, parsed: `[{protocol, host, username, password}]`. */
function readStore(account, { strict = false } = {}) {
const text = readText(account, paths(account).store, { strict }) || "";
const rows = [];
for (const line of text.split("\n")) {
try {
const u = new URL(line.trim());
if (u.protocol !== "https:" || !u.password) continue;
rows.push({
protocol: "https",
host: u.host.toLowerCase(),
username: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
});
} catch {
/* not a credential line */
}
}
return rows;
}
function writeStore(account, rows) {
const enc = (s) => encodeURIComponent(s);
const text = rows
.map((r) => `https://${enc(r.username)}:${enc(r.password)}@${r.host}\n`)
.join("");
writeText(account, paths(account).store, text, { secret: true });
}
function listHttps(account) {
if (account.kind === "host") return gitCredentials.list();
return readStore(account).map(({ protocol, host, username }) => ({
protocol,
host,
username,
hasToken: true,
}));
}
function checkCred(username, password) {
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
throw err("EBADCRED", "username is required");
}
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
throw err("EBADCRED", "password or access token is required");
}
}
function saveHttps(account, { protocol = "https", host, username, password }) {
if (account.kind === "host") return gitCredentials.save({ protocol, host, username, password });
const k = hostKey(protocol, host);
checkCred(username, password);
const rows = readStore(account, { strict: true }).filter((r) => r.host !== k.host);
rows.push({ ...k, username: username.trim(), password });
writeStore(account, rows);
}
function removeHttps(account, protocol, host) {
if (account.kind === "host") return gitCredentials.remove(protocol, host);
const k = hostKey(protocol, host);
const rows = readStore(account, { strict: true });
const kept = rows.filter((r) => r.host !== k.host);
if (kept.length === rows.length) return false;
writeStore(account, kept);
return true;
}
// ── Commit author ──────────────────────────────────────────────────────────
/** The folder a `git config --global` for this account runs in. */
const homeOf = (account) => (account.kind === "member" ? account.acct.home : os.homedir());
/**
* `{name, email}` of the account's global git config (`user.name` /
* `user.email`, null when unset). As the member under S3: their own
* `~/.gitconfig`, which their panes' commits use too.
*/
async function getAuthor(account) {
const { git } = require("./worktree");
const get = (k) =>
git(homeOf(account), ["config", "--global", "--get", k]).then(
(r) => r.stdout.trim() || null,
() => null
);
return { name: await get("user.name"), email: await get("user.email") };
}
/**
* Set the account's commit author. Neither is a secret, so they may cross
* sudo's argv; each is one line, a name up to 100 and an email up to 254
* characters, the email `local@domain`.
*/
async function setAuthor(account, { name, email }) {
const n = typeof name === "string" ? name.trim() : "";
const e = typeof email === "string" ? email.trim() : "";
// eslint-disable-next-line no-control-regex
const ctl = /[\x00-\x1f\x7f]/;
if (!n || n.length > 100 || ctl.test(n)) throw err("EBADAUTHOR", "a name of 1–100 characters");
if (e.length > 254 || ctl.test(e) || !/^[^\s@]+@[^\s@]+$/.test(e)) {
throw err("EBADAUTHOR", "an email like name@example.com");
}
const { git } = require("./worktree");
await git(homeOf(account), ["config", "--global", "user.name", n]);
await git(homeOf(account), ["config", "--global", "user.email", e]);
return getAuthor(account);
}
// ── 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(),
() => ""
);
if (own) return [];
let account;
try {
account = accountForDir(dir);
} catch {
return [];
}
const key = keyFile(account);
return [
"-c",
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -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 key = keyFile(account);
const base = key ? `ssh -i ${shq(key)} -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 (key) persist.push("-c", `core.sshCommand=${base}`);
} 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,
generateSshKey,
removeSshKey,
listHttps,
saveHttps,
removeHttps,
remoteOf,
defaultName,
cloneRepo,
getAuthor,
setAuthor,
sshConfigFor,
memberHttpsConfig,
CLONE_TIMEOUT_MS,
};