feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host

- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
This commit is contained in:
2026-10-15 16:42:47 +07:00
parent 758609775f
commit b058179177
24 changed files with 1510 additions and 126 deletions
+128 -4
View File
@@ -3,7 +3,9 @@
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
* becomes a workspace, the SSH key persisted into the clone, 409
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
* only the public half returned, 0600 on disk), and the member (S3) path of
* only the public half returned, 0600 on disk; named keys beside the default
* ones, SSH logins per alias tied to one key, which a clone then offers
* alone), and the member (S3) path of
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
* git's argv, and that a login typed without "remember" leaves the member's
* store as it was.
@@ -411,7 +413,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
user: "APKAEXAMPLE1",
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
assert.deepEqual(r.body.sshUsers, [
{ host: HOST, user: "APKAEXAMPLE1", alias: null, key: null },
]);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
// A second save replaces, never stacks a second rewrite.
@@ -419,7 +423,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
host: HOST,
user: "APKAEXAMPLE2",
});
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
assert.deepEqual(again.body.sshUsers, [
{ host: HOST, user: "APKAEXAMPLE2", alias: null, key: null },
]);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
assert.deepEqual(del.body.sshUsers, []);
@@ -441,7 +447,9 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
assert.equal(bad.body.error.code, "EBADSSHUSER");
// Ours lists only our shape; a rule the user wrote for another host is kept.
const r = await request("GET", "/api/me/git");
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
assert.deepEqual(r.body.sshUsers, [
{ host: "mine.test", user: "me", alias: null, key: null },
]);
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
} finally {
@@ -450,6 +458,122 @@ describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
});
});
describe("several accounts: named keys and SSH logins per alias", () => {
const getUrl = (url) =>
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
const HOST = "git-codecommit.us-east-1.amazonaws.com";
it("keeps named keys beside the default ones, and deletes one by id", async () => {
await request("DELETE", "/api/me/git/ssh-key");
const a = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "AWS-Prod" });
assert.equal(a.status, 201, JSON.stringify(a.body));
const b = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
const d = await request("POST", "/api/me/git/ssh-key", {});
assert.deepEqual(
d.body.sshKeys.map((k) => [k.id, k.type, k.name]),
[
["id_ed25519", "ed25519", null],
["id_rsa_aws-dev", "rsa", "aws-dev"],
["id_rsa_aws-prod", "rsa", "aws-prod"],
]
);
assert.notEqual(b.body.sshKeys[0].publicKey, a.body.sshKeys[0].publicKey);
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa_aws-prod")).mode & 0o777, 0o600);
// One name per key, whatever its type; a bad name is refused.
const dup = await request("POST", "/api/me/git/ssh-key", { name: "aws-dev" });
assert.equal(dup.status, 409);
const bad = await request("POST", "/api/me/git/ssh-key", { name: "../x" });
assert.equal(bad.body.error.code, "EBADKEYNAME");
const gone = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev");
assert.deepEqual(
gone.body.sshKeys.map((k) => k.id),
["id_ed25519", "id_rsa_aws-prod"]
);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-dev")).status, 404);
assert.equal((await request("DELETE", "/api/me/git/ssh-key?id=../x")).status, 400);
// The terminal command names every key once switched on, and follows them.
await request("PUT", "/api/me/git/ssh-terminal", { on: true });
const cmd = () =>
execFileSync("git", ["config", "--global", "core.sshCommand"], { encoding: "utf8" });
assert.match(cmd(), /id_rsa_aws-prod'/);
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", name: "aws-dev" });
assert.equal(again.body.sshTerminal, "on");
assert.match(cmd(), /id_rsa_aws-dev'/);
await request("PUT", "/api/me/git/ssh-terminal", { on: false });
});
it("logs each alias in as its own user, and a clone of it uses that key alone", async () => {
const prod = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "APKAPROD",
alias: "AWS-Prod",
key: "id_rsa_aws-prod",
});
assert.equal(prod.status, 200, JSON.stringify(prod.body));
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEV", alias: "aws-dev" });
await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "APKADEFAULT" });
const rows = (await request("GET", "/api/me/git")).body.sshUsers;
assert.deepEqual(
rows.map((r) => [r.alias, r.user, r.key]).sort(),
[
["aws-dev", "APKADEV", null],
["aws-prod", "APKAPROD", "id_rsa_aws-prod"],
[null, "APKADEFAULT", null],
].sort()
);
assert.equal(getUrl("ssh://aws-prod/v1/repos/x"), `ssh://APKAPROD@${HOST}/v1/repos/x`);
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), `ssh://APKADEV@${HOST}/v1/repos/x`);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKADEFAULT@${HOST}/v1/repos/x`);
// Refused: an alias with a dot (it could shadow a real host), a key that is not there.
const dot = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "A",
alias: "a.b",
});
assert.equal(dot.body.error.code, "EBADALIAS");
const nokey = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "A",
alias: "x",
key: "id_rsa_nope",
});
assert.equal(nokey.body.error.code, "ENOKEY");
// The test forge stands in for CodeCommit: a longer insteadOf wins.
const bare = path.join(ROOT, "bare.git");
execFileSync("git", [
"config",
"--global",
"--add",
`url.file://${bare}.insteadOf`,
"ssh://aws-prod/v1/repos/r",
]);
const r = await request("POST", "/api/lanes/clone", {
url: "ssh://aws-prod/v1/repos/r",
parent: WORK,
name: "via-alias",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const cmd = execFileSync(
"git",
["-C", path.join(WORK, "via-alias"), "config", "core.sshCommand"],
{ encoding: "utf8" }
).trim();
assert.match(cmd, /^ssh -o IdentityFile='.*\/id_rsa_aws-prod' -o IdentitiesOnly=yes$/);
// Deleting the key unties the login; deleting the login drops the rule.
const unkeyed = await request("DELETE", "/api/me/git/ssh-key?id=id_rsa_aws-prod");
assert.equal(unkeyed.body.sshUsers.find((x) => x.alias === "aws-prod").key, null);
for (const f of ["aws-prod", "aws-dev", HOST]) {
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${f}`)).status, 200);
}
assert.deepEqual((await request("GET", "/api/me/git")).body.sshUsers, []);
assert.equal(getUrl("ssh://aws-dev/v1/repos/x"), "ssh://aws-dev/v1/repos/x");
await request("DELETE", "/api/me/git/ssh-key");
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");