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:
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user