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