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
+92 -7
View File
@@ -267,16 +267,34 @@ describe("/api/me/git", () => {
assert.equal(del.body.ssh, null);
});
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
it("keeps an RSA 4096 key (AWS CodeCommit) BESIDE the ed25519 one", async () => {
const ed = await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { type: "rsa" });
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
assert.equal(rsa.body.ssh.type, "rsa");
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
assert.deepEqual(
rsa.body.sshKeys.map((k) => k.type),
["ed25519", "rsa"]
);
// `ssh` stays the first key, as for a client that knows only one.
assert.equal(rsa.body.ssh.publicKey, ed.body.ssh.publicKey);
assert.match(rsa.body.sshKeys[1].publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
assert.ok(fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was kept");
// Each type is replaced and deleted on its own.
assert.equal((await request("POST", "/api/me/git/ssh-key", { type: "rsa" })).status, 409);
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", replace: true });
assert.notEqual(again.body.sshKeys[1].publicKey, rsa.body.sshKeys[1].publicKey);
assert.equal(again.body.sshKeys[0].publicKey, ed.body.ssh.publicKey);
const noEd = await request("DELETE", "/api/me/git/ssh-key?type=ed25519");
assert.deepEqual(
noEd.body.sshKeys.map((k) => k.type),
["rsa"]
);
assert.equal(noEd.body.ssh.type, "rsa");
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=ed25519")).status, 404);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=dsa")).status, 400);
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
encoding: "utf8",
});
@@ -317,6 +335,64 @@ describe("/api/me/git", () => {
});
});
describe("/api/me/git/ssh-terminal (issue #82)", () => {
const gitGet = () => {
try {
return execFileSync("git", ["config", "--global", "--get", "core.sshCommand"], {
encoding: "utf8",
}).trim();
} catch {
return "";
}
};
it("points the account's terminal git at the key, keeping the ~/.ssh defaults", async () => {
const made = await request("POST", "/api/me/git/ssh-key", { replace: true });
assert.equal(made.status, 201);
// The host's terminal is an admin's switch: never turned on by itself.
assert.equal(made.body.ssh.terminal, "off");
const on = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
assert.equal(on.status, 200);
assert.equal(on.body.ssh.terminal, "on");
const cmd = gitGet();
assert.match(
cmd,
/^ssh -o IdentityFile='.*agenthub-ssh\/id_ed25519' -o IdentityFile='.*id_rsa'/
);
assert.match(cmd, /-o IdentityFile=~\/\.ssh\/id_ed25519/);
// ssh itself reads it: ours first, then the defaults.
const ids = execFileSync(
"sh",
["-c", `${cmd.replace(/^ssh /, "ssh -G -F /dev/null ")} x.test`],
{
encoding: "utf8",
}
)
.split("\n")
.filter((l) => l.startsWith("identityfile "));
assert.match(ids[0], /agenthub-ssh\/id_ed25519$/);
assert.ok(ids.some((l) => l.endsWith("/.ssh/id_ed25519")));
// The dashboard's own fetch still gets its BatchMode command over ours.
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
assert.equal(off.body.ssh.terminal, "off");
assert.equal(gitGet(), "");
});
it("never overwrites a core.sshCommand the user wrote", async () => {
execFileSync("git", ["config", "--global", "core.sshCommand", "ssh -i ~/mine"]);
try {
const r = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "EFOREIGNSSH");
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
assert.equal(off.body.ssh.terminal, "foreign");
assert.equal(gitGet(), "ssh -i ~/mine");
} finally {
execFileSync("git", ["config", "--global", "--unset", "core.sshCommand"]);
}
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");
@@ -356,7 +432,12 @@ describe("git-identity, member account (S3, stubbed)", () => {
it("keeps the key and the logins in the member's own home", async () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
assert.equal(account.kind, "member");
calls = [];
await identity.generateSshKey(account);
// A member's terminal git uses the key from the start (issue #82).
const set = calls.find((c) => c.args[2] === "core.sshCommand");
assert.equal(set.cwd, HOME);
assert.match(set.args[3], /IdentityFile='.*\/users\/an\/\.config\/agenthub\/ssh\/id_ed25519'/);
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
@@ -381,7 +462,11 @@ describe("git-identity, member account (S3, stubbed)", () => {
args[i - 1],
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/
);
assert.match(args[i + 2], /^core\.sshCommand=ssh -i /);
// Persisted by file name, both types: a key made later is offered too.
assert.match(
args[i + 2],
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
);
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
});
+28
View File
@@ -173,6 +173,34 @@ describe("/api/run", () => {
assert.deepEqual(body, { ok: true });
});
it("DELETE /:id?archive=1 archives the run's session; a plain DELETE (Sleep) keeps it", async () => {
const { db } = require("../db");
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-stop", TMP);
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-sleep", TMP);
const run = db.prepare(
"INSERT OR REPLACE INTO dashboard_runs (id, session_id, cwd, status) VALUES (?, ?, ?, 'running')"
);
const archivedAt = (id) =>
db.prepare("SELECT archived_at FROM sessions WHERE id = ?").get(id).archived_at;
tmux.__setExecImpl(() => "");
run.run("agenthub-lane-9-1", "sess-sleep", TMP);
const slept = await fetchJson("/api/run/agenthub-lane-9-1", { method: "DELETE" });
assert.deepEqual(slept.body, { ok: true });
assert.equal(archivedAt("sess-sleep"), null);
run.run("agenthub-lane-9-2", "sess-stop", TMP);
const stopped = await fetchJson("/api/run/agenthub-lane-9-2?archive=1", { method: "DELETE" });
assert.equal(stopped.status, 200);
assert.deepEqual(stopped.body, { ok: true, archived: "sess-stop" });
assert.ok(archivedAt("sess-stop"));
// A pane with no known session (a shell) archives nothing.
run.run("agenthub-lane-9-3", null, TMP);
const shell = await fetchJson("/api/run/agenthub-lane-9-3?archive=1", { method: "DELETE" });
assert.deepEqual(shell.body, { ok: true, archived: null });
});
it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {