diff --git a/client/src/components/settings/GitIdentitySettings.tsx b/client/src/components/settings/GitIdentitySettings.tsx index 4337bf54..79fc26e4 100644 --- a/client/src/components/settings/GitIdentitySettings.tsx +++ b/client/src/components/settings/GitIdentitySettings.tsx @@ -41,32 +41,9 @@ const BTN = "inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40"; const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/; -// The regions AWS CodeCommit runs in. -const AWS_REGIONS = [ - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ap-south-1", - "ap-east-1", - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-central-1", - "eu-north-1", - "eu-south-1", - "sa-east-1", - "me-south-1", - "af-south-1", - "il-central-1", -]; +// The host a key ID is saved under when no URL names one: AWS takes the same +// key ID in every region, so the server writes the default login for all. +const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com"; function Step({ n, children }: { n: number; children: ReactNode }) { return ( @@ -166,28 +143,25 @@ function KeyCard({ } /** - * Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The - * default key's login is the region's plain host; a named key's is saved - * under an account name (its own name unless another is typed), tied to it. + * Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The + * default key's login is saved for every region; a named key's under its own + * name, tied to it. A clone of any CodeCommit URL tries each saved login. */ function AwsLoginForm({ busy, - named, onSave, }: { busy: boolean; - /** The named key's name; undefined for the default key. */ - named?: string; - onSave: (v: { keyId: string; region: string; alias: string }) => Promise; + onSave: (v: { keyId: string }) => Promise; }) { const { t } = useTranslation("settings"); - const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" }); + const [v, setV] = useState({ keyId: "" }); return (
{ e.preventDefault(); - void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" })); + void onSave(v).then((ok) => ok && setV({ keyId: "" })); }} > setV({ ...v, keyId: e.target.value })} className={`${INPUT} font-mono`} /> - - setV({ ...v, alias: e.target.value })} - className={`${INPUT} font-mono`} - /> @@ -413,28 +366,41 @@ export function GitIdentitySettings() { const namedIds = new Set(named.map((k) => k.id)); const rowsOf = (rows: R[], k?: Key) => k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key)); - const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => { + const saveAws = (v: { keyId: string }, k?: Key) => { let user = v.keyId.trim(); - let host = `git-codecommit.${v.region}.amazonaws.com`; - // A pasted `ssh://APKA…@git-codecommit.…/…` URL fills both. + let host = AWS_DEFAULT_HOST; + // A pasted `ssh://APKA…@git-codecommit.…/…` URL: its key ID. The + // region does not matter — the key ID logs in to every one. const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user); if (m?.[1] && m[2]) { user = m[1]; - host = m[2].toLowerCase(); + if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase(); } - // A named key's account goes by the key's name unless another is typed. - const alias = v.alias.trim().toLowerCase() || (k?.name ?? ""); return run( () => - api.gitIdentity.setSshUser({ - host, - user, - ...(alias ? { alias } : {}), - ...(k?.id ? { key: k.id } : {}), - }), - t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) }) + k?.name + ? // A named key (another AWS account): its login goes by the key's + // name; a clone of any CodeCommit URL tries it. + api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! }) + : api.gitIdentity.setSshUser({ host, user, allRegions: true }), + k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll") ); }; + // The default key's logins, one row per key ID: "every region" once the + // server's whole list is covered, else the regions saved (an older save) — + // with a button that extends it to every region. + const regionCount = id?.codecommitRegions?.length ?? 0; + const awsDefaultGroups = (rows: typeof awsRows) => { + const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = []; + for (const r of rows.filter((x) => !x.alias)) { + const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null)); + if (g) { + g.hosts.push(r.host); + g.regions.push(r.region); + } else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] }); + } + return out; + }; const saveGitHost = (host: string, k: Key) => { const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host; return run( @@ -689,14 +655,66 @@ export function GitIdentitySettings() { {id.sshUsers === undefined ? null : ( <> {loginRows( - rows, - (r) => - `${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}` + rows.filter((r) => !!r.alias), + (r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}` )} + {awsDefaultGroups(rows).map((g) => { + const all = regionCount > 0 && g.regions.length >= regionCount; + return ( +
+ + {all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")} + {` · ${g.user} · ${keyLabel(g.key)}`} + + {id.writable && !all && regionCount > 0 && ( + + )} + {id.writable && ( + + )} +
+ ); + })} {id.writable && ( saveAws(v, isNamed ? k : undefined)} /> )} diff --git a/client/src/components/settings/__tests__/GitIdentitySettings.test.tsx b/client/src/components/settings/__tests__/GitIdentitySettings.test.tsx index e5c7dbd9..0bef0f90 100644 --- a/client/src/components/settings/__tests__/GitIdentitySettings.test.tsx +++ b/client/src/components/settings/__tests__/GitIdentitySettings.test.tsx @@ -139,27 +139,64 @@ describe("GitIdentitySettings", () => { await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa")); }); - it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => { + it("saves the CodeCommit SSH key ID for every region — no region to pick", async () => { const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" }; - const H = "git-codecommit.eu-west-1.amazonaws.com"; - vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] })); + const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"]; + const host = (r: string) => `git-codecommit.${r}.amazonaws.com`; + vi.mocked(api.gitIdentity.get).mockResolvedValue( + view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS }) + ); vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue( - view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] }) + view({ + sshKeys: [RSA], + sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })), + codecommitRegions: REGIONS, + }) ); vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue( - view({ sshKeys: [RSA], sshUsers: [] }) + view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS }) ); render(); const aws = await screen.findByRole("region", { name: "AWS CodeCommit" }); + expect(within(aws).queryByLabelText("Region")).toBeNull(); await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 "); - await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1"); await userEvent.click(within(aws).getByRole("button", { name: "Save" })); - expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" }); - expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument(); + expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ + host: "git-codecommit.us-east-1.amazonaws.com", + user: "APKA1", + allRegions: true, + }); + // One row for the key ID, not one per region. + expect(await within(aws).findByText("Every region · APKA1 · Any key")).toBeInTheDocument(); // A CodeCommit host never shows up again under "other hosts". - expect(screen.queryByText(`APKA1@${H}`)).toBeNull(); - await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` })); - expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H); + expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull(); + await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" })); + expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), { + allRegions: true, + user: "APKA1", + }); + }); + + it("offers an older one-region CodeCommit login for every region", async () => { + const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" }; + const H = "git-codecommit.ap-northeast-1.amazonaws.com"; + vi.mocked(api.gitIdentity.get).mockResolvedValue( + view({ + sshKeys: [RSA], + sshUsers: [{ host: H, user: "APKA9" }], + codecommitRegions: ["us-east-1", "ap-northeast-1"], + }) + ); + vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] })); + render(); + const aws = await screen.findByRole("region", { name: "AWS CodeCommit" }); + expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument(); + await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" })); + expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ + host: H, + user: "APKA9", + allRegions: true, + }); }); it("saves a user for any other SSH host under the advanced section", async () => { @@ -247,18 +284,16 @@ describe("GitIdentitySettings", () => { // Another key can be added below it. expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument(); - // Its own step 3: the account name defaults to the key's name. + // Its own step 3: only the key ID; the login goes by the key's name. await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2"); await userEvent.click(within(prod).getByRole("button", { name: "Save" })); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ - host: H, + host: "git-codecommit.us-east-1.amazonaws.com", user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2", }); - expect( - await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2") - ).toBeInTheDocument(); + expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument(); // The default key's card does not list the named key's login. const def = screen.getByRole("region", { name: "AWS CodeCommit" }); expect(within(def).queryByText(/APKA2/)).toBeNull(); @@ -298,8 +333,6 @@ describe("GitIdentitySettings", () => { alias: "git-2", key: "id_ed25519_git-2", }); - expect( - await within(work).findByText("ssh://git-2/ → git@github.com") - ).toBeInTheDocument(); + expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument(); }); }); diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index a73654ec..9211ac03 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -911,11 +911,13 @@ "awsSub": "RSA 4096 key — AWS accepts only RSA", "stepCopy": "Copy the public key.", "awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.", - "awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:", + "awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here — one key ID works in every region:", "awsKeyId": "SSH key ID", - "awsRegion": "Region", "awsSave": "Save", - "awsSaved": "SSH key ID saved for {{region}}", + "awsSavedAll": "SSH key ID saved for every region", + "awsSavedNamed": "SSH key ID saved for {{name}}", + "awsAllRegions": "Every region", + "awsUseAll": "Use in every region", "otherTitle": "Other SSH hosts (advanced)", "otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh:///… address logs in as that user.", "otherHost": "Host", @@ -929,7 +931,7 @@ "alias": "Account name", "aliasPlaceholder": "name (optional)", "aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh:///…", - "awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh:///v1/repos/.", + "awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.", "tabGit": "GitHub · GitLab", "tabOther": "Other hosts", "addKey": "Add another key", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 018eba3f..c1d2c9b4 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -899,11 +899,13 @@ "awsSub": "Key RSA 4096 — AWS chỉ nhận RSA", "stepCopy": "Copy public key.", "awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.", - "awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:", + "awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây — một key ID dùng được cho mọi region:", "awsKeyId": "SSH key ID", - "awsRegion": "Region", "awsSave": "Lưu", - "awsSaved": "Đã lưu SSH key ID cho {{region}}", + "awsSavedAll": "Đã lưu SSH key ID cho mọi region", + "awsSavedNamed": "Đã lưu SSH key ID cho {{name}}", + "awsAllRegions": "Mọi region", + "awsUseAll": "Dùng cho mọi region", "otherTitle": "Host SSH khác (nâng cao)", "otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh:///… trơn sẽ đăng nhập bằng user đó.", "otherHost": "Host", @@ -917,7 +919,7 @@ "alias": "Tên tài khoản", "aliasPlaceholder": "tên (tuỳ chọn)", "aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh:///…", - "awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh:///v1/repos/.", + "awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.", "tabGit": "GitHub · GitLab", "tabOther": "Host khác", "addKey": "Thêm key khác", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ef9345b5..46b2e1d4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3231,11 +3231,23 @@ export const api = { * the user): for the host itself, or with `alias` one more account on it * (`ssh:///…`); `key` = the only key it uses. */ - setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) => - request("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }), - /** DELETE a login by its host (the host's own rule) or its alias. */ - removeSshUser: (from: string) => - request(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }), + /** `allRegions`: AWS CodeCommit's default login, for every region at once. */ + setSshUser: (body: { + host: string; + user: string; + alias?: string; + key?: string; + allRegions?: boolean; + }) => request("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }), + /** DELETE a login by its host (the host's own rule) or its alias; + * `allRegions` (a CodeCommit host): every region's login of `user`. */ + removeSshUser: (from: string, opts?: { allRegions: true; user: string }) => + request( + `/me/git/ssh-user/${encodeURIComponent(from)}${ + opts ? `?allRegions=1&user=${encodeURIComponent(opts.user)}` : "" + }`, + { method: "DELETE" } + ), saveHttps: (body: { host: string; username: string; password: string }) => request("/me/git/https", { method: "POST", body: JSON.stringify(body) }), removeHttps: (host: string) => @@ -4945,6 +4957,9 @@ export interface GitIdentity { * it uses, null = every key. Absent from an older server. */ sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[]; + /** The regions a CodeCommit key ID saved for every region covers. Absent + * from an older server. */ + codecommitRegions?: string[]; /** The account's commit author (`git config --global`); null fields = unset. */ author: { name: string | null; email: string | null }; /** The suggested email while none is set: `@smartgift.vn`. */ diff --git a/docs/API.md b/docs/API.md index 14296883..c241c7c0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1203,7 +1203,7 @@ GET /api/me/git { account: "member"|"host", writable, ssh: { POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id__` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape DELETE /api/me/git/ssh-key Delete one key — `?id=` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched -PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://@/".insteadOf "ssh:///"` to the account's `~/.gitconfig`, `` = `alias` or the host itself (replacing ours for that host/alias), so `ssh:///…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub..sshKey` there: a clone of a `ssh:///…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit..amazonaws.com/…` whose region has no login of its own borrows the ones saved for other CodeCommit regions (each with its own key): the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. 400 EBADHOST / EBADSSHUSER +PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/?allRegions=1&user=` removes them all. Otherwise it writes `url."ssh://@/".insteadOf "ssh:///"` to the account's `~/.gitconfig`, `` = `alias` or the host itself (replacing ours for that host/alias), so `ssh:///…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub..sshKey` there: a clone of a `ssh:///…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit..amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh:///` rewrite. 400 EBADHOST / EBADSSHUSER DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched POST /api/me/git/https Save an https login — Body: { host, username, password } DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND) diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 358c8a13..a9fdc984 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -886,7 +886,7 @@ What runs as the member, and how: - In a member's folder, git and `gh` run as `sudo -n -u ah- git …`, so git's own ownership check passes and every file stays the member's. - The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`. - A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo. - - AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh:///v1/repos/`; a GitHub one's ties the key to its host — clone `ssh:////.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://@/".insteadOf "ssh:///"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit..amazonaws.com/v1/repos/` works in a pane and in Clone — and Clone also lends a region with no key ID of its own the key IDs saved for other regions (an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked. + - AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh:////.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://@/".insteadOf "ssh:///"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit..amazonaws.com/v1/repos/` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked. - **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are: - the workspace prompt and its context files; - the AI Proxy settings: no key, only that pane's own token for the gateway; diff --git a/server/__tests__/git-clone.test.js b/server/__tests__/git-clone.test.js index 0504b5e9..4c135b78 100644 --- a/server/__tests__/git-clone.test.js +++ b/server/__tests__/git-clone.test.js @@ -5,7 +5,9 @@ * ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once, * 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; a CodeCommit region with no login borrowing another region's), and the member (S3) path of + * alone; a CodeCommit region with no login borrowing another region's; one key ID + * saved for every region; a second account's login tried when the region's own is + * refused), 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. @@ -688,6 +690,91 @@ describe("CodeCommit: a region with no login borrows another region's", () => { }); }); +describe("CodeCommit: one key ID for every region", () => { + const getUrl = (url) => + execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim(); + const { CODECOMMIT_REGIONS } = require("../lib/git-identity"); + + it("saves the default login for every region, and removes it in one go", async () => { + const r = await request("PUT", "/api/me/git/ssh-user", { + host: "git-codecommit.us-east-1.amazonaws.com", + user: "APKAALL", + allRegions: true, + }); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.sshUsers.length, CODECOMMIT_REGIONS.length); + assert.deepEqual(r.body.codecommitRegions, CODECOMMIT_REGIONS); + for (const region of ["ap-northeast-1", "eu-west-1"]) { + const h = `git-codecommit.${region}.amazonaws.com`; + assert.equal(getUrl(`ssh://${h}/v1/repos/x`), `ssh://APKAALL@${h}/v1/repos/x`); + } + // A second every-region save replaces each region's rule, never stacks. + const again = await request("PUT", "/api/me/git/ssh-user", { + host: "git-codecommit.us-east-1.amazonaws.com", + user: "APKAALL2", + allRegions: true, + }); + assert.equal(again.body.sshUsers.length, CODECOMMIT_REGIONS.length); + assert.ok(again.body.sshUsers.every((u) => u.user === "APKAALL2")); + // An alias is never every region's. + const bad = await request("PUT", "/api/me/git/ssh-user", { + host: "git-codecommit.us-east-1.amazonaws.com", + user: "APKAX", + alias: "prod", + allRegions: true, + }); + assert.equal(bad.status, 400); + const del = await request( + "DELETE", + "/api/me/git/ssh-user/git-codecommit.us-east-1.amazonaws.com?allRegions=1&user=APKAALL2" + ); + assert.equal(del.status, 200); + assert.deepEqual(del.body.sshUsers, []); + }); + + it("tries a second account's login when the region's own is refused", async () => { + const bare = path.join(ROOT, "bare.git"); + const R = "git-codecommit.ap-northeast-1.amazonaws.com"; + assert.equal( + (await request("PUT", "/api/me/git/ssh-user", { host: R, user: "APKAMINE" })).status, + 200 + ); + assert.equal( + ( + await request("PUT", "/api/me/git/ssh-user", { + host: "git-codecommit.us-east-1.amazonaws.com", + user: "APKAOTHER", + alias: "other-acct", + }) + ).status, + 200 + ); + const rules = [ + [`url.file://${ROOT}/nowhere.git.insteadOf`, `ssh://APKAMINE@${R}/v1/repos/r`], + [`url.file://${bare}.insteadOf`, `ssh://APKAOTHER@${R}/v1/repos/r`], + ]; + for (const [k, v] of rules) execFileSync("git", ["config", "--global", "--add", k, v]); + try { + const r = await request("POST", "/api/lanes/clone", { + url: `ssh://${R}/v1/repos/r`, + parent: WORK, + name: "second-account", + }); + assert.equal(r.status, 201, JSON.stringify(r.body)); + const origin = execFileSync( + "git", + ["-C", path.join(WORK, "second-account"), "config", "remote.origin.url"], + { encoding: "utf8" } + ).trim(); + assert.equal(origin, `ssh://APKAOTHER@${R}/v1/repos/r`); + } finally { + for (const [k] of rules) execFileSync("git", ["config", "--global", "--unset-all", k]); + await request("DELETE", `/api/me/git/ssh-user/${R}`); + await request("DELETE", "/api/me/git/ssh-user/other-acct"); + } + }); +}); + describe("git-identity, member account (S3, stubbed)", () => { const runAs = require("../lib/run-as"); const worktree = require("../lib/worktree"); diff --git a/server/lib/git-identity.js b/server/lib/git-identity.js index 0aa11a31..a5341909 100644 --- a/server/lib/git-identity.js +++ b/server/lib/git-identity.js @@ -569,6 +569,38 @@ const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/; const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; const insteadOfBase = (host, user) => `ssh://${user}@${host}/`; +// AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY +// region of that AWS account. Saved with `allRegions`, the default login is +// written for each region here, so a plain `ssh://git-codecommit.…/` URL +// logs in — in a pane's terminal too — and the user never picks a region. +const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/; +const CODECOMMIT_REGIONS = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "sa-east-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "eu-north-1", + "eu-south-1", + "il-central-1", + "me-south-1", + "af-south-1", + "ap-east-1", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", +]; +const codecommitHost = (region) => `git-codecommit.${region}.amazonaws.com`; + /** `Map`: the key each login names (`agenthub..sshKey`). */ async function keyLinks(account) { const { git } = require("./worktree"); @@ -642,10 +674,16 @@ async function listSshUsers(account) { * 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) { +async function removeSshUser(account, from, { allRegions = false, user = null } = {}) { const { git } = require("./worktree"); const f = String(from || "").toLowerCase(); - const rows = (await listSshUsers(account)).filter((r) => (r.alias ?? r.host) === f); + // `allRegions` (from a CodeCommit host): every region's own rule — of + // `user` only when given — never an alias (a named key's login). + const rows = (await listSshUsers(account)).filter((r) => + allRegions && CODECOMMIT_RE.test(f) + ? r.alias === null && CODECOMMIT_RE.test(r.host) && (!user || r.user === user) + : (r.alias ?? r.host) === f + ); for (const r of rows) { await git(homeOf(account), [ "config", @@ -653,10 +691,10 @@ async function removeSshUser(account, from) { "--fixed-value", "--unset-all", `url.${insteadOfBase(r.host, r.user)}.insteadOf`, - `ssh://${f}/`, + `ssh://${r.alias ?? r.host}/`, ]); + await unlinkKey(account, r.alias ?? r.host); } - if (rows.length) await unlinkKey(account, f); return rows.length > 0; } @@ -666,7 +704,10 @@ async function removeSshUser(account, from) { * 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 } = {}) { +async function setSshUser( + account, + { host, user, alias = null, key = null, allRegions = false } = {} +) { const h = String(host || "") .trim() .toLowerCase(); @@ -682,9 +723,28 @@ async function setSshUser(account, { host, user, alias = null, key = null } = {} if (key != null && key !== "" && !currentKeys(account).some((k) => k.id === key)) { throw err("ENOKEY", "no such SSH key"); } + const { git } = require("./worktree"); + if (allRegions === true) { + if (a !== null || !CODECOMMIT_RE.test(h)) { + throw err("EBADHOST", "every region is for AWS CodeCommit's own login, without an alias"); + } + // One login for the whole AWS account: every region's rule is replaced. + await removeSshUser(account, h, { allRegions: true }); + for (const region of CODECOMMIT_REGIONS) { + const rh = codecommitHost(region); + await git(homeOf(account), [ + "config", + "--global", + "--add", + `url.${insteadOfBase(rh, u)}.insteadOf`, + `ssh://${rh}/`, + ]); + if (key) await git(homeOf(account), ["config", "--global", `agenthub.${rh}.sshKey`, key]); + } + return listSshUsers(account); + } const from = a ?? h; await removeSshUser(account, from); - const { git } = require("./worktree"); await git(homeOf(account), [ "config", "--global", @@ -715,12 +775,12 @@ async function linkedKeyFile(account, url) { return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null; } -// AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY -// region of that AWS account — but our login rules are per host, and a repo -// in a region the user never saved stayed refused. Such a clone borrows the -// logins saved for the other CodeCommit regions (not aliases' hosts only: an -// alias row's user is as valid), each with its own key when it names one. -const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/; +// A CodeCommit URL with no user is tried with every CodeCommit login saved +// (the region's own first, then other regions' and every named key's — an +// alias row's user is as valid), each with its own key when it names one: +// a login saved for one region, or a second AWS account's key, clones from +// any region without the user rewriting the URL. The first the remote +// accepts wins (`cloneNow`). /** `[{url, keyFile}]`: `url` with each borrowable CodeCommit user in it. */ async function borrowedLogins(account, url) { @@ -733,9 +793,12 @@ async function borrowedLogins(account, url) { } const host = u.host.toLowerCase(); if (u.username || !CODECOMMIT_RE.test(host)) return []; - const rows = await listSshUsers(account); - // The region has a login of its own: git's insteadOf already applies it. - if (rows.some((r) => (r.alias ?? r.host) === host)) return []; + const all = await listSshUsers(account); + // The region's own login first: it is the likeliest to be the right one. + const rows = [ + ...all.filter((r) => r.alias === null && r.host === host), + ...all.filter((r) => !(r.alias === null && r.host === host)), + ]; const keys = currentKeys(account); const seen = new Set(); const out = []; @@ -748,6 +811,8 @@ async function borrowedLogins(account, url) { seen.add(id); out.push({ url: `ssh://${r.user}@${host}${u.pathname}`, keyFile }); } + // One login in all, and it is the region's own: git's insteadOf applies it. + if (out.length === 1 && rows[0]?.alias === null && rows[0]?.host === host) return []; return out; } @@ -1050,6 +1115,7 @@ module.exports = { listSshUsers, setSshUser, removeSshUser, + CODECOMMIT_REGIONS, listHttps, saveHttps, removeHttps, diff --git a/server/routes/git-identity.js b/server/routes/git-identity.js index c8c6a21b..98a6c8c1 100644 --- a/server/routes/git-identity.js +++ b/server/routes/git-identity.js @@ -58,6 +58,8 @@ async function view(account, username) { sshKeys: keys, sshTerminal: terminal ?? null, sshUsers: await identity.listSshUsers(account), + // The regions a CodeCommit key ID saved for "every region" covers. + codecommitRegions: identity.CODECOMMIT_REGIONS, https: identity.listHttps(account), author: await identity.getAuthor(account), // What the author email form suggests while none is set. @@ -136,10 +138,12 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => { }); /** - * 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 + * An SSH login (`{host, user, alias?, key?, allRegions?}`) — e.g. AWS + * CodeCommit's SSH key ID, so a plain `ssh://git-codecommit…/` URL logs in + * (`allRegions`: for every CodeCommit region at once); with `alias`, one more * account on that host (`ssh:///…`); with `key`, the only key it uses. - * DELETE `/ssh-user/` drops it. + * DELETE `/ssh-user/` drops it (`?allRegions=1&user=` every + * region's). */ router.put("/ssh-user", sameOriginGuard, async (req, res) => { try { @@ -154,7 +158,16 @@ router.put("/ssh-user", sameOriginGuard, async (req, res) => { router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => { try { const account = accountOf(req, { write: true }); - if (!(await identity.removeSshUser(account, req.params.host))) { + // `?allRegions=1[&user=APKA…]` from a CodeCommit host: every region's + // own login (of that key ID) at once. + const opts = + req.query.allRegions === "1" + ? { + allRegions: true, + user: typeof req.query.user === "string" && req.query.user ? req.query.user : null, + } + : {}; + if (!(await identity.removeSshUser(account, req.params.host, opts))) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } }); } res.json(await view(account, usernameOf(req)));