feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix

- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:51:26 +07:00
parent 58be04a4e7
commit 0a1dcfeb7f
28 changed files with 813 additions and 225 deletions
+132 -37
View File
@@ -5,7 +5,7 @@
*
* 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`
* `~/.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
@@ -20,6 +20,10 @@
* 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).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -64,7 +68,8 @@ function accountForRequest(req) {
/**
* 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.
* `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" };
@@ -208,38 +213,48 @@ function fingerprint(publicKey) {
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
}
/** `{type, file}` of the account's key (ed25519 first), or null. */
function currentKey(account) {
/** `[{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;
return { type, file, publicKey: publicKey.trim() };
keys.push({ type, file, publicKey: publicKey.trim() });
}
return null;
return keys;
}
/** The private key file a clone or fetch uses, or null. */
const keyFile = (account) => currentKey(account)?.file ?? null;
/** The private key files a clone or fetch offers (ed25519 first), maybe none. */
const keyFiles = (account) => currentKeys(account).map((k) => k.file);
/** `{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;
/** 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 (`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.
* 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 (sshKeyInfo(account) && !replace) {
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
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")
@@ -251,22 +266,26 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
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);
// 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;
}
function removeSshKey(account) {
const k = currentKey(account);
if (!k) return false;
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
/**
* 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;
}
@@ -386,6 +405,69 @@ async function setAuthor(account, { name, email }) {
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);
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
@@ -409,17 +491,18 @@ async function sshConfigFor(dir) {
(r) => r.stdout.trim(),
() => ""
);
if (own) return [];
let account;
try {
account = accountForDir(dir);
} catch {
return [];
}
const key = keyFile(account);
// 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${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
];
}
@@ -526,12 +609,20 @@ async function cloneRepo({ url, parent, name, once = 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";
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 (key) persist.push("-c", `core.sshCommand=${base}`);
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 });
@@ -617,8 +708,12 @@ module.exports = {
makeRsaKeyPair,
fingerprint,
sshKeyInfo,
sshKeysInfo,
generateSshKey,
removeSshKey,
terminalSshCommand,
terminalSshState,
setTerminalSsh,
listHttps,
saveHttps,
removeHttps,