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");
|
||||
|
||||
+212
-43
@@ -23,8 +23,14 @@
|
||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* overwritten), plus a per-host SSH login name (`setSshUser`: AWS CodeCommit
|
||||
* wants the IAM SSH key ID as the user) as a `url.<…>.insteadOf` rule there.
|
||||
* overwritten), plus SSH logins (`setSshUser`: AWS CodeCommit wants the IAM
|
||||
* SSH key ID as the user) as `url.<…>.insteadOf` rules there — one per host,
|
||||
* or one per ALIAS (`ssh://aws-prod/…`), so several accounts on one host
|
||||
* (two AWS accounts, two GitHub users) each log in as themselves. Besides
|
||||
* the default key of each type, an account may hold named keys
|
||||
* (`ssh/id_<type>_<name>`), and a login may name the key it uses
|
||||
* (`agenthub.<from>.sshKey` in the same file): a clone of that login's URL
|
||||
* offers ONLY that key and keeps it in the clone's config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -74,6 +80,12 @@ function accountForRequest(req) {
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
/** A named key's name, and every key's id (= its file name in `sshDir`). */
|
||||
const KEY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const KEY_ID_RE = /^id_(ed25519|rsa)(?:_([a-z0-9][a-z0-9-]{0,31}))?$/;
|
||||
/** ssh offers every key in turn and servers stop after ~6 tries. */
|
||||
const MAX_KEYS = 10;
|
||||
const keyIdFor = (type, name) => (name ? `${KEY_FILES[type]}_${name}` : KEY_FILES[type]);
|
||||
|
||||
function paths(account) {
|
||||
if (account.kind === "member") {
|
||||
@@ -214,17 +226,32 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
|
||||
/**
|
||||
* `[{id, type, name, file, publicKey}]` of the account's keys: the default
|
||||
* ed25519 and rsa ones first (`name` null), then the named ones by name.
|
||||
*/
|
||||
function currentKeys(account) {
|
||||
const { sshDir } = paths(account);
|
||||
let ids;
|
||||
try {
|
||||
ids = fs.readdirSync(sshDir).filter((n) => KEY_ID_RE.test(n));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const rank = (id) => {
|
||||
const i = Object.values(KEY_FILES).indexOf(id);
|
||||
return i < 0 ? Object.keys(KEY_FILES).length : i;
|
||||
};
|
||||
ids.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
const keys = [];
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
for (const id of ids) {
|
||||
const [, type, name = null] = KEY_ID_RE.exec(id);
|
||||
const file = path.join(sshDir, id);
|
||||
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;
|
||||
keys.push({ type, file, publicKey: publicKey.trim() });
|
||||
keys.push({ id, type, name, file, publicKey: publicKey.trim() });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -235,10 +262,12 @@ const keyFiles = (account) => currentKeys(account).map((k) => k.file);
|
||||
/** ssh options naming `files` — `-i` each, in order. */
|
||||
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
|
||||
|
||||
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
/** `[{id, type, name, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
function sshKeysInfo(account) {
|
||||
return currentKeys(account).map((k) => ({
|
||||
id: k.id,
|
||||
type: k.type,
|
||||
name: k.name,
|
||||
publicKey: k.publicKey,
|
||||
fingerprint: fingerprint(k.publicKey),
|
||||
}));
|
||||
@@ -248,14 +277,30 @@ function sshKeysInfo(account) {
|
||||
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
|
||||
|
||||
/**
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa); an
|
||||
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
|
||||
* other type is left alone — a host refusing one type keeps the other.
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa) — the
|
||||
* default one of that type, or with `name` one more beside it (another AWS
|
||||
* account, another GitHub user). An existing key of that id is replaced only
|
||||
* with `replace`; every other key is left alone.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
async function generateSshKey(
|
||||
account,
|
||||
{ replace = false, comment, type = "ed25519", name = null } = {}
|
||||
) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (currentKeys(account).some((k) => k.type === type) && !replace) {
|
||||
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
|
||||
const keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
|
||||
if (keyName !== null && !KEY_NAME_RE.test(keyName)) {
|
||||
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
|
||||
}
|
||||
const id = keyIdFor(type, keyName);
|
||||
const keys = currentKeys(account);
|
||||
if (keys.some((k) => k.id === id) && !replace) {
|
||||
throw err("EEXISTS", `the SSH key ${keyName ?? type} already exists — replace it explicitly`);
|
||||
}
|
||||
if (keyName !== null && keys.some((k) => k.name === keyName && k.id !== id)) {
|
||||
throw err("EEXISTS", `a key named ${keyName} already exists`);
|
||||
}
|
||||
if (!keys.some((k) => k.id === id) && keys.length >= MAX_KEYS) {
|
||||
throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
|
||||
}
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
@@ -264,29 +309,44 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
|
||||
const { privateKey, publicKey } =
|
||||
type === "rsa" ? await makeRsaKeyPair(label) : makeKeyPair(label);
|
||||
const { sshDir } = paths(account);
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const file = path.join(sshDir, id);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
// A member's home is AgentHub's to set up: their terminal git uses the key
|
||||
// too, unless they wrote a core.sshCommand of their own. The host's is
|
||||
// its admin's choice (the Settings switch).
|
||||
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
|
||||
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
|
||||
// its admin's choice (the Settings switch) — once on, it follows the keys.
|
||||
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account's key of `type`, or every key when `type` is omitted;
|
||||
* false when there was none. The terminal sshCommand goes with the last key.
|
||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||
* neither is given; false when there was none. A login naming a deleted key
|
||||
* goes back to offering every key; the terminal sshCommand follows the keys
|
||||
* and goes with the last one.
|
||||
*/
|
||||
async function removeSshKey(account, type) {
|
||||
async function removeSshKey(account, which) {
|
||||
const { type, id } = typeof which === "string" ? { type: which } : which || {};
|
||||
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
|
||||
if (id != null && !KEY_ID_RE.test(id)) throw err("EBADKEYNAME", "no such key id");
|
||||
const gone = currentKeys(account).filter((k) =>
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
}
|
||||
const ids = new Set(gone.map((k) => k.id));
|
||||
for (const [from, key] of await keyLinks(account)) {
|
||||
if (ids.has(key)) await unlinkKey(account, from);
|
||||
}
|
||||
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
|
||||
else if ((await terminalSshState(account)) === "on") {
|
||||
await setTerminalSsh(account, true).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -427,11 +487,28 @@ async function setAuthor(account, { name, email }) {
|
||||
*/
|
||||
function terminalSshCommand(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
|
||||
const files = new Set([
|
||||
...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)),
|
||||
...currentKeys(account).map((k) => k.file),
|
||||
]);
|
||||
const ours = [...files].map((f) => `-o IdentityFile=${shq(f)}`);
|
||||
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
|
||||
return ["ssh", ...ours, ...defaults].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `cmd` is a terminal sshCommand WE wrote — of our shape, naming
|
||||
* key files in this account's AgentHub folder (any set of them: it follows
|
||||
* the keys as they are made and deleted) and nothing else but the defaults.
|
||||
*/
|
||||
function isOurTerminal(account, cmd) {
|
||||
const m = /^ssh((?: -o IdentityFile=(?:'[^']*'|~\/\.ssh\/id_[a-z0-9]+))+)$/.exec(cmd || "");
|
||||
if (!m) return false;
|
||||
const files = [...m[1].matchAll(/IdentityFile='([^']*)'/g)].map((x) => x[1]);
|
||||
const { sshDir } = paths(account);
|
||||
return files.length > 0 && files.every((f) => path.dirname(f) === sshDir);
|
||||
}
|
||||
|
||||
/** The account's global `core.sshCommand`, or "" when unset. */
|
||||
async function globalSshCommand(account) {
|
||||
const { git } = require("./worktree");
|
||||
@@ -449,7 +526,7 @@ async function globalSshCommand(account) {
|
||||
async function terminalSshState(account) {
|
||||
const cmd = await globalSshCommand(account);
|
||||
if (!cmd) return "off";
|
||||
return cmd === terminalSshCommand(account) ? "on" : "foreign";
|
||||
return isOurTerminal(account, cmd) ? "on" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,7 +541,8 @@ async function setTerminalSsh(account, on) {
|
||||
if (state === "foreign") {
|
||||
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
|
||||
}
|
||||
if (state === "off") {
|
||||
// Off, or ours naming another set of keys than the account holds now.
|
||||
if (state === "off" || (await globalSshCommand(account)) !== terminalSshCommand(account)) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
@@ -487,9 +565,47 @@ async function setTerminalSsh(account, on) {
|
||||
// Clone dialog's and the git bar's — without touching `~/.ssh/config`. The
|
||||
// user name is no secret (an ID, not a key), so it may cross sudo's argv.
|
||||
const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
||||
// An alias has no dot, so it can never shadow a real host name.
|
||||
const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
|
||||
|
||||
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
|
||||
/** `Map<from, key id>`: the key each login names (`agenthub.<from>.sshKey`). */
|
||||
async function keyLinks(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--get-regexp",
|
||||
"^agenthub\\..*\\.sshkey$",
|
||||
]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = new Map();
|
||||
for (const line of String(out).split("\n")) {
|
||||
const m = /^agenthub\.(.+)\.sshkey (\S+)$/.exec(line);
|
||||
if (m && KEY_ID_RE.test(m[2])) links.set(m[1], m[2]);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
async function unlinkKey(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
`agenthub.${from}.sshKey`,
|
||||
]).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* `[{host, user, alias, key}]` — only the rules of exactly our shape; any
|
||||
* other insteadOf is the user's. `alias` is null for the host's own rule
|
||||
* (`ssh://<host>/` logs in as `user`), else the name standing for that
|
||||
* user@host (`ssh://<alias>/`); `key` is the key id the login uses, or null
|
||||
* for every key.
|
||||
*/
|
||||
async function listSshUsers(account) {
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(homeOf(account), [
|
||||
@@ -501,21 +617,35 @@ async function listSshUsers(account) {
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const links = await keyLinks(account);
|
||||
const rows = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = /^url\.ssh:\/\/([^@/\s]+)@([^/\s]+)\/\.insteadof ssh:\/\/([^/\s]+)\/$/.exec(line);
|
||||
if (m && m[2] === m[3] && SSH_USER_RE.test(m[1]) && HOST_RE.test(m[2])) {
|
||||
rows.push({ host: m[2], user: m[1] });
|
||||
if (
|
||||
m &&
|
||||
(m[2] === m[3] || ALIAS_RE.test(m[3])) &&
|
||||
SSH_USER_RE.test(m[1]) &&
|
||||
HOST_RE.test(m[2])
|
||||
) {
|
||||
rows.push({
|
||||
host: m[2],
|
||||
user: m[1],
|
||||
alias: m[2] === m[3] ? null : m[3],
|
||||
key: links.get(m[3]) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Drop our rule for `host` (whatever its user); false when there was none. */
|
||||
async function removeSshUser(account, host) {
|
||||
/**
|
||||
* Drop our rule for `from` — a host's own rule, or an alias's — and the key
|
||||
* it names; false when there was none.
|
||||
*/
|
||||
async function removeSshUser(account, from) {
|
||||
const { git } = require("./worktree");
|
||||
const h = String(host || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
|
||||
const f = String(from || "").toLowerCase();
|
||||
const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f);
|
||||
for (const r of rows) {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
@@ -523,34 +653,68 @@ async function removeSshUser(account, host) {
|
||||
"--fixed-value",
|
||||
"--unset-all",
|
||||
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
|
||||
`ssh://${r.host}/`,
|
||||
`ssh://${f}/`,
|
||||
]);
|
||||
}
|
||||
if (rows.length) await unlinkKey(account, f);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Log in to `host` over SSH as `user` (replaces our earlier rule for that host). */
|
||||
async function setSshUser(account, { host, user } = {}) {
|
||||
/**
|
||||
* Log in to `host` over SSH as `user` — for `ssh://<host>/` itself, or, with
|
||||
* `alias`, for `ssh://<alias>/` (one more account on the same host) — with
|
||||
* the key `key` only, or every key when it is null. Replaces our earlier rule
|
||||
* for that host or alias.
|
||||
*/
|
||||
async function setSshUser(account, { host, user, alias = null, key = null } = {}) {
|
||||
const h = String(host || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const u = String(user || "").trim();
|
||||
const a = alias == null || alias === "" ? null : String(alias).trim().toLowerCase();
|
||||
if (!HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "a host name like git-codecommit.us-east-1.amazonaws.com");
|
||||
}
|
||||
if (!SSH_USER_RE.test(u)) throw err("EBADSSHUSER", "a user name of letters, digits, . _ -");
|
||||
await removeSshUser(account, h);
|
||||
if (a !== null && !ALIAS_RE.test(a)) {
|
||||
throw err("EBADALIAS", "an account name of lowercase letters, digits and - (no dot)");
|
||||
}
|
||||
if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) {
|
||||
throw err("ENOKEY", "no such SSH key");
|
||||
}
|
||||
const from = a ?? h;
|
||||
await removeSshUser(account, from);
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
`url.${insteadOfBase(h, u)}.insteadOf`,
|
||||
`ssh://${h}/`,
|
||||
`ssh://${from}/`,
|
||||
]);
|
||||
if (key) await git(homeOf(account), ["config", "--global", `agenthub.${from}.sshKey`, key]);
|
||||
return listSshUsers(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key file a clone of `url` must use: the one its login names, when the
|
||||
* URL is `ssh://<host or alias>/…` with no user of its own (the only shape
|
||||
* our insteadOf rules rewrite). Null: offer every key.
|
||||
*/
|
||||
async function linkedKeyFile(account, url) {
|
||||
if (!url.startsWith("ssh://")) return null;
|
||||
let from;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.username) return null;
|
||||
from = u.host.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = (await listSshUsers(account)).find((r) => (r.alias ?? r.host) === from && r.key);
|
||||
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
@@ -582,7 +746,7 @@ async function sshConfigFor(dir) {
|
||||
}
|
||||
// Our own terminal sshCommand (global) is not the repo's choice: it has no
|
||||
// BatchMode, so it would leave a dashboard fetch waiting on a prompt.
|
||||
if (own && own !== terminalSshCommand(account)) return [];
|
||||
if (own && !isOurTerminal(account, own)) return [];
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
@@ -692,18 +856,23 @@ 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 keys = keyFiles(account);
|
||||
// A login naming its key (another AWS account, a second GitHub user)
|
||||
// offers that key ONLY — another key could log in as the wrong account.
|
||||
const linked = await linkedKeyFile(account, url);
|
||||
const keys = linked ? [linked] : keyFiles(account);
|
||||
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
||||
if (keys.length) {
|
||||
// Persisted by FILE name, both types: a key made later is offered too
|
||||
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
|
||||
// Persisted by FILE name — both default types plus every named key:
|
||||
// a default key made later is offered too (ssh skips a missing
|
||||
// IdentityFile silently, where `-i` would warn). A linked key alone.
|
||||
const { sshDir } = paths(account);
|
||||
const all = Object.values(KEY_FILES)
|
||||
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`)
|
||||
.join("");
|
||||
const files = linked
|
||||
? [linked]
|
||||
: [...new Set([...Object.values(KEY_FILES).map((f) => path.join(sshDir, f)), ...keys])];
|
||||
const all = files.map((f) => ` -o IdentityFile=${shq(f)}`).join("");
|
||||
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
|
||||
}
|
||||
} else if (account.kind === "member") {
|
||||
|
||||
@@ -276,17 +276,23 @@ const paths = {
|
||||
"/api/me/git/ssh-key": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Make (or with `replace`, replace) the SSH key of `type`; the other type is kept",
|
||||
summary:
|
||||
"Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it",
|
||||
operationId: "generateSshKey",
|
||||
requestBody: json({
|
||||
replace: { type: "boolean" },
|
||||
type: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
|
||||
}),
|
||||
responses: { 201: { description: "The identity." } },
|
||||
responses: {
|
||||
201: { description: "The identity." },
|
||||
400: { description: "EBADKEYNAME" },
|
||||
409: { description: "EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS" },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Delete the SSH key of ?type=ed25519|rsa, or every key",
|
||||
summary: "Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key",
|
||||
operationId: "deleteSshKey",
|
||||
parameters: [
|
||||
{
|
||||
@@ -295,6 +301,7 @@ const paths = {
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
},
|
||||
{ name: "id", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: { 200: { description: "The identity." } },
|
||||
},
|
||||
@@ -311,6 +318,36 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-user": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary:
|
||||
"An SSH login: url.<ssh://user@host/>.insteadOf ssh://<alias or host>/, optionally tied to one key",
|
||||
operationId: "setSshUser",
|
||||
requestBody: json(
|
||||
{
|
||||
host: { type: "string" },
|
||||
user: { type: "string" },
|
||||
alias: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,62}$" },
|
||||
key: { type: "string" },
|
||||
},
|
||||
["host", "user"]
|
||||
),
|
||||
responses: {
|
||||
200: { description: "The identity; `sshUsers: [{host, user, alias, key}]`." },
|
||||
400: { description: "EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-user/{from}": {
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Drop the SSH login of a host or alias (and its key link)",
|
||||
operationId: "deleteSshUser",
|
||||
parameters: [{ name: "from", in: "path", required: true, schema: { type: "string" } }],
|
||||
responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } },
|
||||
},
|
||||
},
|
||||
"/api/me/git/https": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (make, replace, delete —
|
||||
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
||||
* `hasToken` only, never a password), whether the account's own terminal
|
||||
* git uses that key (`core.sshCommand`), the SSH login name per host
|
||||
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — a default ed25519
|
||||
* and/or rsa one plus named ones (`name`, deleted by `?id=`) for more
|
||||
* accounts (make, replace, delete — only the PUBLIC key is ever returned) and
|
||||
* the HTTPS logins (save, delete — `hasToken` only, never a password),
|
||||
* whether the account's own terminal git uses the keys (`core.sshCommand`),
|
||||
* the SSH logins per host or alias (`url.<…>.insteadOf` — AWS CodeCommit's
|
||||
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
|
||||
* `user.email` of the account's global git config — a member's commits fail
|
||||
* without one; a new SSH key is labelled with that email, else
|
||||
* `<username>@smartgift.vn`). The account is the signed-in user's
|
||||
@@ -33,6 +35,10 @@ const STATUS = {
|
||||
EBADKEYTYPE: 400,
|
||||
EFOREIGNSSH: 409,
|
||||
EBADSSHUSER: 400,
|
||||
EBADKEYNAME: 400,
|
||||
ETOOMANYKEYS: 409,
|
||||
EBADALIAS: 400,
|
||||
ENOKEY: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -87,9 +93,10 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
await identity.generateSshKey(account, {
|
||||
replace: req.body?.replace === true,
|
||||
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
|
||||
// A named key sits beside the default one of its type.
|
||||
name: typeof req.body?.name === "string" ? req.body.name : null,
|
||||
// The key is labelled with the commit author's email, else the default one.
|
||||
comment:
|
||||
(await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
|
||||
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
|
||||
});
|
||||
res.status(201).json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
@@ -101,7 +108,8 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const type = req.query.type === undefined ? undefined : String(req.query.type);
|
||||
if (!(await identity.removeSshKey(account, type))) {
|
||||
const id = req.query.id === undefined ? undefined : String(req.query.id);
|
||||
if (!(await identity.removeSshKey(account, { type, id }))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
@@ -128,8 +136,10 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The SSH user name for one host (`{host, user}`) — e.g. AWS CodeCommit's SSH
|
||||
* key ID, so a plain `ssh://git-codecommit…/` URL logs in. DELETE drops it.
|
||||
* An SSH login (`{host, user, alias?, key?}`) — e.g. AWS CodeCommit's SSH key
|
||||
* ID, so a plain `ssh://git-codecommit…/` URL logs in; with `alias`, one more
|
||||
* account on that host (`ssh://<alias>/…`); with `key`, the only key it uses.
|
||||
* DELETE `/ssh-user/<host or alias>` drops it.
|
||||
*/
|
||||
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user