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:
+89
-26
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user