feat(git identity): one section per git host; CodeCommit SSH key ID makes a plain ssh:// clone work

AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a
plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x`
was refused as the Linux account name. The account can now save an SSH
user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` —
written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its
own ~/.gitconfig (as the member under S3), so the plain URL works in a
pane, in Clone and in the git bar. Only rules of exactly that shape are
listed or removed; any other insteadOf is the user's and left alone.

Settings → Git → Your git identity now shows the SSH keys one section
per kind of host, each with only its own numbered steps: GitHub · GitLab
· Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in
IAM → paste the key ID and pick the region, then a "Ready" clone line),
plus a collapsed "Other SSH hosts (advanced)". The shared key-type
picker is gone: each section has its own Generate/Replace/Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:31:30 +07:00
parent 1c5bd88747
commit 276cead826
10 changed files with 630 additions and 109 deletions
+50
View File
@@ -393,6 +393,56 @@ describe("/api/me/git/ssh-terminal (issue #82)", () => {
});
});
describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
const getUrl = (url) =>
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
const HOST = "git-codecommit.ap-southeast-1.amazonaws.com";
it("makes a plain ssh:// URL log in as the saved user, and only for that host", async () => {
const r = await request("PUT", "/api/me/git/ssh-user", {
host: ` ${HOST.toUpperCase()} `,
user: "APKAEXAMPLE1",
});
assert.equal(r.status, 200);
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
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.
const again = await request("PUT", "/api/me/git/ssh-user", {
host: HOST,
user: "APKAEXAMPLE2",
});
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
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, []);
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://${HOST}/v1/repos/x`);
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${HOST}`)).status, 404);
});
it("refuses a bad host or user, and leaves the user's own insteadOf alone", async () => {
const own = "url.ssh://me@mine.test/.insteadOf";
execFileSync("git", ["config", "--global", own, "ssh://mine.test/"]);
try {
assert.equal(
(await request("PUT", "/api/me/git/ssh-user", { host: "-oProxy", user: "a" })).body.error
.code,
"EBADHOST"
);
const bad = await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "a@b" });
assert.equal(bad.status, 400);
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.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 {
execFileSync("git", ["config", "--global", "--unset", own]);
}
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");
+78 -1
View File
@@ -23,7 +23,8 @@
* 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).
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -468,6 +469,79 @@ async function setTerminalSsh(account, on) {
return terminalSshState(account);
}
// ── SSH login name per host (`~/.gitconfig` url.<…>.insteadOf) ─────────────
// AWS CodeCommit logs an SSH key in by its IAM key ID (`APKA…`) as the user
// name, so `ssh://git-codecommit…/v1/repos/x` (no user) is refused. One
// `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` per host makes the
// plain URL work for every git of the account — a clone typed in a pane, the
// 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}$/;
const insteadOfBase = (host, user) => `ssh://${user}@${host}/`;
/** `[{host, user}]` — only the rules of exactly our shape; any other insteadOf is the user's. */
async function listSshUsers(account) {
const { git } = require("./worktree");
const out = await git(homeOf(account), [
"config",
"--global",
"--get-regexp",
"^url\\..*\\.insteadof$",
]).then(
(r) => r.stdout,
() => ""
);
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] });
}
}
return rows;
}
/** Drop our rule for `host` (whatever its user); false when there was none. */
async function removeSshUser(account, host) {
const { git } = require("./worktree");
const h = String(host || "").toLowerCase();
const rows = (await listSshUsers(account)).filter((r) => r.host === h);
for (const r of rows) {
await git(homeOf(account), [
"config",
"--global",
"--fixed-value",
"--unset-all",
`url.${insteadOfBase(r.host, r.user)}.insteadOf`,
`ssh://${r.host}/`,
]);
}
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 } = {}) {
const h = String(host || "")
.trim()
.toLowerCase();
const u = String(user || "").trim();
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);
const { git } = require("./worktree");
await git(homeOf(account), [
"config",
"--global",
"--add",
`url.${insteadOfBase(h, u)}.insteadOf`,
`ssh://${h}/`,
]);
return listSshUsers(account);
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
@@ -714,6 +788,9 @@ module.exports = {
terminalSshCommand,
terminalSshState,
setTerminalSsh,
listSshUsers,
setSshUser,
removeSshUser,
listHttps,
saveHttps,
removeHttps,
+30 -1
View File
@@ -3,7 +3,8 @@
* 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`), and the commit author (`user.name` /
* 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` /
* `user.email` of the account's global git config — a member's commits fail
* without one). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
@@ -29,6 +30,7 @@ const STATUS = {
ESTOREUNREADABLE: 409,
EBADKEYTYPE: 400,
EFOREIGNSSH: 409,
EBADSSHUSER: 400,
};
function fail(res, e) {
@@ -47,6 +49,7 @@ async function view(account) {
ssh: keys[0] ? { ...keys[0], terminal } : null,
sshKeys: keys,
sshTerminal: terminal ?? null,
sshUsers: await identity.listSshUsers(account),
https: identity.listHttps(account),
author: await identity.getAuthor(account),
};
@@ -115,6 +118,32 @@ 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.
*/
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setSshUser(account, req.body || {});
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!(await identity.removeSshUser(account, req.params.host))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
}
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
router.post("/https", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });