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>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+89 -26
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` (+ `.pub`) and `git-credentials`
* `~/.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
@@ -61,18 +61,26 @@ function accountForRequest(req) {
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", "id_ed25519"),
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, "id_ed25519"), store: null };
return { base: sshDir, sshDir, key: path.join(sshDir, KEY_FILES.ed25519), store: null };
}
/**
@@ -130,6 +138,31 @@ const u32 = (n) => {
};
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
@@ -175,36 +208,65 @@ function fingerprint(publicKey) {
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
}
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
function sshKeyInfo(account) {
const { key } = paths(account);
const publicKey = readText(account, `${key}.pub`);
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
// The private half is only looked at, never read into this process.
if (!isOwnFile(account, key)) return null;
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
/** `{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;
}
/** Make the account's key; an existing one is replaced only with `replace`. */
function generateSshKey(account, { replace = false, comment } = {}) {
/** 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);
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
const { key } = paths(account);
writeText(account, key, privateKey, { secret: true });
writeText(account, `${key}.pub`, publicKey, { secret: false });
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 { key } = paths(account);
if (!sshKeyInfo(account)) return false;
removeFile(account, key);
removeFile(account, `${key}.pub`);
const k = currentKey(account);
if (!k) return false;
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
return true;
}
@@ -354,7 +416,7 @@ async function sshConfigFor(dir) {
} catch {
return [];
}
const key = sshKeyInfo(account) ? paths(account).key : null;
const key = keyFile(account);
return [
"-c",
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
@@ -464,7 +526,7 @@ 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 = sshKeyInfo(account) ? paths(account).key : null;
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.
@@ -552,6 +614,7 @@ module.exports = {
accountForDir,
accountForRequest,
makeKeyPair,
makeRsaKeyPair,
fingerprint,
sshKeyInfo,
generateSshKey,