Files
AgentHub/server/routes/git-identity.js
T
nntrivi2001 276cead826 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>
2026-10-15 13:31:30 +07:00

182 lines
5.8 KiB
JavaScript

/**
* @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` /
* `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
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const identity = require("../lib/git-identity");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const STATUS = {
EBADHOST: 400,
EBADCRED: 400,
EEXISTS: 409,
EMEMBERGONE: 409,
EHOSTIDENTITY: 403,
EBADAUTHOR: 400,
ESTOREUNREADABLE: 409,
EBADKEYTYPE: 400,
EFOREIGNSSH: 409,
EBADSSHUSER: 400,
};
function fail(res, e) {
res
.status(STATUS[e.code] || 500)
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
async function view(account) {
const keys = identity.sshKeysInfo(account);
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
return {
account: account.kind,
writable: account.kind === "member" || account.writable !== false,
// `ssh`: the first key, as before there could be two; `sshKeys`: all.
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),
};
}
/** The request's account; a write to the host's identity needs an admin. */
function accountOf(req, { write = false } = {}) {
const account = identity.accountForRequest(req);
if (write && account.kind === "host" && !account.writable) {
throw Object.assign(new Error("the host's git identity is an admin's to change"), {
code: "EHOSTIDENTITY",
});
}
return account;
}
router.get("/", async (req, res) => {
try {
res.json(await view(accountOf(req)));
} catch (e) {
fail(res, e);
}
});
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.generateSshKey(account, {
replace: req.body?.replace === true,
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
});
res.status(201).json(await view(account));
} catch (e) {
fail(res, e);
}
});
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))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
/**
* Whether a plain `git` in the account's own terminal uses the AgentHub key
* (`core.sshCommand` in its `~/.gitconfig`, issue #82). `{on: boolean}`.
*/
router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.sshKeyInfo(account) && req.body?.on === true) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
await identity.setTerminalSsh(account, req.body?.on === true);
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
/**
* 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 });
const { host, username, password } = req.body || {};
identity.saveHttps(account, { protocol: "https", host, username, password });
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
router.delete("/https/:host", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.removeHttps(account, "https", req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
}
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
/** The account's commit author (`git config --global user.name/email`). */
router.put("/author", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setAuthor(account, req.body || {});
res.json(await view(account));
} catch (e) {
fail(res, e);
}
});
module.exports = router;