feat(git-identity): commit signing per workspace, and Delete in the signing card

Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.

The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.

Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
This commit is contained in:
2026-11-02 16:49:14 +07:00
parent 246b554dab
commit 2342f54179
11 changed files with 747 additions and 150 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
@@ -20,10 +20,11 @@
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. **Commit signing**
* (under the author): one picker signs every commit with one of the keys
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
* default, signs with it in the same step. The private key and
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
* sign with Delete, a way out of the old account-wide setting and of a
* signing setup the user made themselves, and **Import a key you have** takes
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -44,6 +45,7 @@ import {
Upload,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
@@ -233,9 +235,13 @@ function GitHostForm({
}
/**
* Commit signing: the key every commit is signed with (one picker — choosing
* a key turns signing on, "Don't sign" off) and Import a key the user already
* has, which signs with it in the same step unless unticked.
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
* workspaces that need it sign), the keys that can sign, each with Delete,
* and Import a key the user already has. The old account-wide setting
* (every repo signed) only shows when it is on, with a button that turns it
* off; a signing setup the user made themselves in `~/.gitconfig` can be
* taken out (their key file stays).
*/
function SigningCard({
id,
@@ -251,51 +257,90 @@ function SigningCard({
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
const laneSigning = id.laneSigning ?? [];
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
const choose = async (keyId: string | null) => {
if (keyId && signing.state === "foreign") {
if (
!(await confirmDialog({
title: t("gitIdentity.signReplaceConfirm", {
path: signing.path,
key: keyLabel(keyId),
}),
confirmLabel: t("gitIdentity.signReplace"),
}))
)
return;
}
useEffect(() => {
let live = true;
api.lanes
.list()
.then((r) => live && setLanes(r.lanes))
.catch(() => live && setLanes([]));
return () => {
live = false;
};
}, []);
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
const laneName = (l: Lane) => l.title || l.cwd;
// Still signing, but its workspace is gone from the list (removed): the
// folder keeps signing until it is turned off here.
const orphans =
lanes === null
? []
: laneSigning
.filter((x) => !lanes.some((l) => l.id === x.laneId))
.map(
(x) =>
({
id: x.laneId,
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
cwd: "",
}) as Lane
);
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
const chooseLane = async (lane: Lane, keyId: string | null) =>
run(
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
keyId
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
);
const clearForeign = async () => {
if (
!(await confirmDialog({
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
confirmLabel: t("gitIdentity.signForeignRemove"),
}))
)
return;
await run(
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
() => api.gitIdentity.setSigning(null, false, true),
t("gitIdentity.signForeignRemoved")
);
};
const deleteKey = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(
() => api.gitIdentity.removeKeyById(k.id!),
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
);
};
const doImport = async () => {
if (!form) return;
let imported: string | null = null;
let foreign = false;
const ok = await run(
async () => {
const r = await api.gitIdentity.importKey({
() =>
api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
sign: form.sign,
});
imported = r.imported?.id ?? null;
foreign = r.signError?.code === "EFOREIGNSIGN";
return r;
},
}),
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (!ok) return;
setForm(null);
// Saved, but the user's own signing setup was left alone: ask once.
if (imported && foreign) await choose(imported);
if (ok) setForm(null);
};
const readFile = async (file: File | undefined) => {
@@ -306,6 +351,8 @@ function SigningCard({
setForm({ ...form, text });
};
const usable = keys.filter((k) => k.id);
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
@@ -321,7 +368,7 @@ function SigningCard({
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "", sign: true })}
onClick={() => setForm({ text: "", name: "" })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
@@ -330,52 +377,138 @@ function SigningCard({
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
<div className="flex flex-wrap items-center gap-2">
<span
data-testid="git-signing-state"
className={`min-w-0 flex-1 text-[11px] ${
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
}`}
{signing.state === "on" && (
<div
data-testid="git-signing-legacy"
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
>
{signing.state === "on"
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
: signing.state === "foreign"
? t("gitIdentity.signForeign", { path: signing.path })
: t("gitIdentity.signOff")}
</span>
{keys.length > 0 && (
<select
aria-label={t("gitIdentity.signKey")}
value={signing.state === "on" ? (signing.key ?? "") : ""}
disabled={busy || !id.writable}
onChange={(e) => void choose(e.target.value || null)}
className="select !py-1 !text-xs"
>
<option value="">
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
</option>
{keys
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
</span>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
}
>
{t("gitIdentity.signLegacyOff")}
</button>
)}
</div>
)}
{signing.state === "foreign" && (
<div
data-testid="git-signing-foreign"
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("gitIdentity.signForeign", { path: signing.path })}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void clearForeign()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.signForeignRemove")}
</button>
)}
</div>
)}
<div className="space-y-1" data-testid="git-signing-lanes">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signLanes")}
</div>
{usable.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
) : (
[...mine, ...orphans].map((l) => {
const on = signedWith(l.id);
return (
<div key={l.id} className="flex items-center gap-2">
<span
className={`min-w-0 flex-1 truncate text-xs ${
on ? "text-status-success" : "text-fg-primary"
}`}
title={l.cwd || undefined}
>
{laneName(l)}
</span>
<select
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
value={on?.key ?? ""}
disabled={busy || !id.writable}
onChange={(e) => void chooseLane(l, e.target.value || null)}
className="select !w-48 !py-1 !text-xs"
>
<option value="">{t("gitIdentity.signNone")}</option>
{usable.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
</div>
);
})
)}
</div>
{current && (
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{usable.length > 0 && (
<div className="space-y-1" data-testid="git-signing-keys">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signKeys")}
</div>
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
</Step>
{usable.map((k) => {
const used = laneSigning.filter((x) => x.key === k.id).length;
return (
<div
key={k.id}
className="flex items-center gap-2 rounded border border-border px-2 py-1"
>
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
<div className="truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
</div>
</div>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
title={t("gitIdentity.signHostStep")}
>
<Copy className="h-3.5 w-3.5" />
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
onClick={() => void deleteKey(k)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
);
})}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
</div>
)}
{form && (
@@ -407,24 +540,14 @@ function SigningCard({
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<div className="flex flex-wrap items-center gap-3">
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={form.sign}
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
/>
{t("gitIdentity.importSign")}
</label>
</div>
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
<div className="flex gap-2">
<button
@@ -8,8 +8,10 @@
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host;
* commit signing: a key picked signs commits, an imported key signs in the
* same step, and taking over the user's own signing key asks first.
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
* workspace's picker signs that workspace only, a signing key is deleted from
* the card after a confirm, and the old account-wide setting and a setup the
* user made themselves each have a way out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -35,6 +37,16 @@ vi.mock("../../../lib/api", () => ({
removeHttps: vi.fn(),
importKey: vi.fn(),
setSigning: vi.fn(),
setLaneSigning: vi.fn(),
},
lanes: {
list: vi.fn(async () => ({
lanes: [
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
{ id: 2, title: "", cwd: "/w/docs" },
],
counts: {},
})),
},
},
}));
@@ -42,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
const OFF = { state: "off" as const, key: null, path: null };
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
account: "member",
@@ -340,7 +353,7 @@ describe("GitIdentitySettings", () => {
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
it("signs commits with an imported key in one step", async () => {
it("imports a key without signing anything by itself", async () => {
const signingKey = {
...KEY,
id: "id_ed25519_signing",
@@ -348,17 +361,15 @@ describe("GitIdentitySettings", () => {
name: "signing",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
...view({
sshKeys: [signingKey],
signing: { state: "on", key: "id_ed25519_signing", path: null },
}),
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
imported: signingKey,
signError: null,
});
render(<GitIdentitySettings />);
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
const form = screen.getByRole("form", { name: "Import an existing private key" });
await userEvent.type(
@@ -368,26 +379,103 @@ describe("GitIdentitySettings", () => {
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
sign: true,
});
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
expect(confirmDialog).not.toHaveBeenCalled();
// Now there is a key: each workspace gets its own picker, all "Don't sign".
expect(
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
).toHaveValue("");
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("asks before taking over a signing key the user set up themselves", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
it("signs one workspace only, and stops it", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
vi.mocked(api.gitIdentity.setLaneSigning)
.mockResolvedValueOnce(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
)
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
render(<GitIdentitySettings />);
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
await userEvent.selectOptions(pick, k.id);
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
await waitFor(() =>
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
);
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
).toBeTruthy();
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("lists a removed workspace that still signs, so it can be turned off", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
);
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
expect(confirmDialog).toHaveBeenCalled();
const pick = await screen.findByLabelText(
"Signing key for Workspace #9 (removed)",
{},
{ timeout: 5000 }
);
expect(pick).toHaveValue(k.id);
await userEvent.selectOptions(pick, "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
await waitFor(() =>
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
);
});
it("deletes a signing key from the signing card, after asking", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({
sshKeys: [k],
signing: { state: "off", key: null, path: null },
laneSigning: [{ laneId: 1, key: k.id }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
);
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
});
it("turns off the old account-wide signing and removes a setup the user made", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
);
render(<GitIdentitySettings />);
const legacy = await screen.findByTestId("git-signing-legacy");
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
const foreign = await screen.findByTestId("git-signing-foreign");
expect(foreign).toHaveTextContent("~/.ssh/mine");
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
);
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
});
});
+21 -9
View File
@@ -884,23 +884,35 @@
"authorEmail": "Email",
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
"signTitle": "Commit signing",
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
"signOff": "Commits are not signed.",
"signOn": "Every commit is signed with {{key}}.",
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
"signLanes": "Workspaces",
"signLanesEmpty": "You have no workspace yet.",
"signNeedKey": "Import a key you have, or make one below, to sign with.",
"signLaneGone": "Workspace #{{id}} (removed)",
"signLaneKey": "Signing key for {{name}}",
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
"signLaneStopped": "{{name}} no longer signs its commits.",
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
"signLegacyOff": "Sign per workspace only",
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
"signForeignRemove": "Remove",
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
"signKeys": "Keys you can sign with",
"signKeyUsed_one": "signs {{count}} workspace",
"signKeyUsed_other": "signs {{count}} workspaces",
"signKeyCopy": "Copy the public key of {{key}}",
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
"signKeyDeleted": "Key {{key}} deleted.",
"signKey": "Signing key",
"signNone": "Don't sign",
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
"signReplace": "Switch",
"signSaved": "Commits are now signed with {{key}}.",
"signStopped": "Commits are no longer signed.",
"importKey": "Import a key you have",
"importTitle": "Import an existing private key",
"importFile": "Choose the key file",
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Name (optional)",
"importSign": "Sign my commits with it",
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
"importSubmit": "Import",
"imported": "Key {{name}} imported.",
+21 -9
View File
@@ -872,23 +872,35 @@
"authorEmail": "Email",
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
"signTitle": "Ký commit",
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
"signOff": "Commit chưa được ký.",
"signOn": "Mọi commit đang được ký bằng {{key}}.",
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
"signLanes": "Workspace",
"signLanesEmpty": "Bạn chưa có workspace nào.",
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
"signLaneGone": "Workspace #{{id}} (đã xóa)",
"signLaneKey": "Key ký cho {{name}}",
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
"signLaneStopped": "{{name}} không còn ký commit.",
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
"signLegacyOff": "Chỉ ký theo workspace",
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
"signForeignRemove": "Gỡ",
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
"signKeys": "Key dùng để ký",
"signKeyUsed_one": "ký {{count}} workspace",
"signKeyUsed_other": "ký {{count}} workspace",
"signKeyCopy": "Sao chép public key của {{key}}",
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
"signKeyDeleted": "Đã xóa key {{key}}.",
"signKey": "Key dùng để ký",
"signNone": "Không ký",
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
"signReplace": "Chuyển",
"signSaved": "Commit giờ được ký bằng {{key}}.",
"signStopped": "Commit không còn được ký.",
"importKey": "Nhập key có sẵn",
"importTitle": "Nhập private key có sẵn",
"importFile": "Chọn file key",
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Tên (không bắt buộc)",
"importSign": "Dùng key này để ký commit",
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
"importSubmit": "Nhập",
"imported": "Đã nhập key {{name}}.",
+18 -3
View File
@@ -3528,11 +3528,21 @@ export const api = {
"/me/git/ssh-key/import",
{ method: "POST", body: JSON.stringify(body) }
),
/** PUT /api/me/git/signing — sign commits with key `key` (its id), or stop (null). */
setSigning: (key: string | null, replace = false) =>
/**
* PUT /api/me/git/signing — the account-wide setting: sign every commit
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
* signing setup the user made themselves (their key file stays).
*/
setSigning: (key: string | null, replace = false, clearForeign = false) =>
request<GitIdentity>("/me/git/signing", {
method: "PUT",
body: JSON.stringify({ key, replace }),
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
}),
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
setLaneSigning: (laneId: number, key: string | null) =>
request<GitIdentity>("/me/git/signing/lane", {
method: "PUT",
body: JSON.stringify({ laneId, key }),
}),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
@@ -5450,6 +5460,11 @@ export interface GitIdentity {
* from an older server.
*/
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
/**
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
* `key` = the key's id, null when its file is gone. Absent from an older server.
*/
laneSigning?: { laneId: number; key: string | null }[];
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/**
+2 -1
View File
@@ -1563,7 +1563,8 @@ 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_<type>_<name>` — 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=<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
POST /api/me/git/ssh-key/import Import a private key the user already has — Body: { privateKey, name?, sign?, replace? } — OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) or PEM (PKCS#1/PKCS#8) text, ed25519 or RSA ≥ 2048; saved as a NAMED key (`name`, else `signing`, `signing-2`…) written like a generated one, so it serves clones too; only `imported` (its public half) comes back. 400 EBADKEY (not a key, a `.pub`, halves that do not match), EKEYPASSPHRASE (an encrypted key — a dashboard commit has nowhere to type the passphrase), EBADKEYTYPE; 409 EEXISTS (the same key already imported, or the name taken), ETOOMANYKEYS. With `sign: true` commits are signed with it at once; a refusal of that step comes back as `signError` (the key stays saved) → 201
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign)
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign) — the old ACCOUNT-WIDE setting; `clearForeign: true` (with `key: null`) also takes out a signing setup the user made themselves, never their key file
PUT /api/me/git/signing/lane Sign ONE workspace's commits — Body: { laneId, key: <key id> | null } — a file of ours `lane-<id>.gitconfig` (gpg.format ssh, user.signingkey, commit.gpgsign) included from `~/.gitconfig` by `includeIf "gitdir:<the workspace's git dir>"` and `"gitdir:<common dir>/worktrees/ah-<id>-*"` (its sessions' worktrees), so only that workspace signs — terminal, Changes panel and agents alike — and nothing is written into the repository. 404 for a workspace the caller cannot see, 403 EFOREIGN when its folder runs as another account, 409 ENOREPO when it is not a git repository. `GET` answers `laneSigning: [{ laneId, key }]`; deleting a key stops every workspace signing with it. Two workspaces on one folder share it; a workspace in a SUBFOLDER of a repo signs the whole repo
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?, 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/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` 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.<from>.sshKey` there: a clone of a `ssh://<from>/…` 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.<region>.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://<alias>/` 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
+129 -1
View File
@@ -5,7 +5,11 @@
* the same public half `ssh-keygen -y` prints, only that half returned, the
* private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key
* twice refused; a real commit signed and verified by git; a signing key the
* user set themselves kept unless replaced; deleting the key stops signing.
* user set themselves kept unless replaced, and taken out (its file kept) on
* `clearForeign`; deleting the key stops signing. Per workspace
* (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its
* sessions' `ah-<id>-*` worktrees — are signed, never another repo's, the
* user's own includes stay, and deleting the key stops that workspace's too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -204,3 +208,127 @@ describe("PUT /api/me/git/signing", () => {
await removeAll();
});
});
describe("PUT /api/me/git/signing/lane", () => {
const env = {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "me@x",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "me@x",
};
const commit = (dir, msg) =>
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env });
/** `G` signed and verified, `N` not signed. */
const sig = (dir, allowed) =>
execFileSync(
"git",
["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
{ encoding: "utf8" }
).trim();
const lane = async (dir) => {
const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true });
return r.body.lane.id;
};
it("signs one workspace and its sessions' worktrees, never another repo", async () => {
const A = path.join(ROOT, "ws-a");
const B = path.join(ROOT, "ws-b");
const plain = path.join(ROOT, "ws-plain");
for (const d of [A, B]) {
execFileSync("git", ["init", "-q", d]);
commit(d, "first");
}
fs.mkdirSync(plain);
const [a, b, p] = [await lane(A), await lane(B), await lane(plain)];
// A user's own include stays whatever we do.
execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]);
const k = keygen("lane", ["-t", "ed25519"]);
const imp = await request("POST", "/api/me/git/ssh-key/import", {
privateKey: k.text,
name: "lane",
});
const key = imp.body.imported.id;
const allowed = path.join(ROOT, "allowed-lane");
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
assert.equal(on.status, 200);
assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]);
// Nothing account-wide: the global settings stay unset.
assert.equal(on.body.signing.state, "off");
assert.equal(gitGet("commit.gpgsign"), "");
commit(A, "in a");
commit(B, "in b");
assert.equal(sig(A, allowed), "G");
assert.equal(sig(B, allowed), "N");
// A session's worktree of A is signed; any other worktree of A is not.
const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`);
const other = path.join(ROOT, "other-wt");
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]);
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]);
commit(wt, "in session worktree");
commit(other, "in other worktree");
assert.equal(sig(wt, allowed), "G");
assert.equal(sig(other, allowed), "N");
// Nothing was written into the repository itself.
assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/);
// Not a repo: refused; an unknown workspace: 404.
const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key });
assert.equal(notRepo.status, 409);
assert.equal(notRepo.body.error.code, "ENOREPO");
const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key });
assert.equal(gone.status, 404);
// Off: unsigned again, our file gone, the user's include kept.
const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null });
assert.deepEqual(off.body.laneSigning, []);
commit(A, "after off");
assert.equal(sig(A, allowed), "N");
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
// A workspace removed from the dashboard while it signed can still be turned off.
await request("PUT", "/api/me/git/signing/lane", { laneId: b, key });
await request("POST", `/api/lanes/${b}/remove`, {});
const orphan = await request("GET", "/api/me/git");
assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]);
const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null });
assert.equal(stopGone.status, 200);
assert.deepEqual(stopGone.body.laneSigning, []);
const b2 = await lane(B);
// Deleting the key stops every workspace that signs with it.
await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key });
const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`);
assert.equal(del.status, 200);
assert.deepEqual(del.body.laneSigning, []);
commit(A, "after the key went");
assert.equal(sig(A, allowed), "N");
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
assert.deepEqual(
fs
.readdirSync(path.join(ROOT, ".claude", "agenthub-signing"))
.filter((n) => n.endsWith(".gitconfig")),
[]
);
execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]);
await removeAll();
});
it("takes out a signing setup the user made themselves, keeping their key file", async () => {
const mine = path.join(ROOT, "my-own-key");
fs.writeFileSync(mine, "not ours");
execFileSync("git", ["config", "--global", "user.signingkey", mine]);
execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]);
const kept = await request("PUT", "/api/me/git/signing", { key: null });
assert.equal(kept.body.signing.state, "foreign");
const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true });
assert.equal(r.status, 200);
assert.equal(r.body.signing.state, "off");
assert.equal(gitGet("user.signingkey"), "");
assert.equal(gitGet("commit.gpgsign"), "");
assert.equal(fs.readFileSync(mine, "utf8"), "not ours");
});
});
+146 -3
View File
@@ -574,7 +574,7 @@ async function signingState(account) {
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
* the key's PATH crosses sudo's argv, never its content.
*/
async function setSigning(account, { id = null, replace = false } = {}) {
async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) {
const { git } = require("./worktree");
const home = homeOf(account);
const state = await signingState(account);
@@ -583,7 +583,9 @@ async function setSigning(account, { id = null, replace = false } = {}) {
/* already unset */
});
if (id == null) {
if (state.state === "foreign") return state;
// The user's own setup is left alone unless they asked to take it out
// (`clearForeign`): only the three settings go, never their key file.
if (state.state === "foreign" && !clearForeign) return state;
if ((await globalGet(account, "user.signingkey")) !== "") {
await unset("commit.gpgsign");
await unset("user.signingkey");
@@ -605,6 +607,138 @@ async function setSigning(account, { id = null, replace = false } = {}) {
return signingState(account);
}
// ── Commit signing per workspace (`includeIf "gitdir:…"`) ──────────────────
//
// A workspace is signed by a small config file of OURS per workspace
// (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign),
// pulled into the account's `~/.gitconfig` by `includeIf "gitdir:<dir>"` for
// the workspace's own git dir and for the git dirs of the worktrees its
// sessions make (`ah-<id>-*`, pty-run.js). Every git that reads the account's
// config — the terminal, the Changes panel, an agent — then signs there and
// nowhere else; nothing is written into the user's repository.
const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/;
/** Where the per-workspace files live: ours, beside the account's keys. */
function signingDir(account) {
return account.kind === "member"
? path.join(paths(account).base, "signing")
: path.join(getClaudeHome(), "agenthub-signing");
}
const laneSignFile = (account, laneId) =>
path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`);
/** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */
async function ourIncludes(account) {
const { git } = require("./worktree");
const out = await git(homeOf(account), [
"config",
"--global",
"--null",
"--get-regexp",
"^includeif\\..*\\.path$",
]).then(
(r) => r.stdout,
() => ""
);
const dir = signingDir(account);
const rows = [];
// `--null`: "key\nvalue\0" — a git dir or a path may hold spaces.
for (const rec of out.split("\0")) {
const at = rec.indexOf("\n");
if (at <= 0) continue;
const key = rec.slice(0, at);
const value = rec.slice(at + 1);
if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) {
rows.push({ key, value });
}
}
return rows;
}
/** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */
async function dropIncludes(account, file) {
const { git } = require("./worktree");
for (const r of await ourIncludes(account)) {
if (r.value !== file) continue;
await git(homeOf(account), [
"config",
"--global",
"--fixed-value",
"--unset-all",
r.key,
r.value,
]).catch(() => {});
}
}
/** The key file a workspace file signs with (its `signingkey` line), or null. */
function laneSignKeyFile(account, file) {
const text = readText(account, file) || "";
const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text);
return m ? m[1].replace(/\\(["\\])/g, "$1") : null;
}
/**
* Which workspaces sign their commits, and with which key:
* `[{laneId, key}]` (`key` null when the key file it names is gone).
*/
async function laneSigningList(account) {
const keys = currentKeys(account);
const seen = new Map();
for (const r of await ourIncludes(account)) {
const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]);
if (seen.has(laneId)) continue;
const keyFile = laneSignKeyFile(account, r.value);
seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null);
}
return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId);
}
const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`;
/**
* Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop
* (`id` null). The git dirs come from git itself, run in the workspace's
* folder as its account — never from the request. ENOREPO when the folder is
* not a git repository.
*/
async function setLaneSigning(account, { laneId, cwd, id = null }) {
const n = Number(laneId);
if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace");
const file = laneSignFile(account, n);
await dropIncludes(account, file);
if (id == null) {
removeFile(account, file);
return;
}
const k = currentKeys(account).find((x) => x.id === id);
if (!k) throw err("ENOKEY", "no such SSH key");
const { git } = require("./worktree");
const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim());
let gitDir;
let commonDir;
try {
gitDir = await rev(["--absolute-git-dir"]);
commonDir = await rev(["--path-format=absolute", "--git-common-dir"]);
} catch {
throw err("ENOREPO", "this workspace's folder is not a git repository");
}
writeText(
account,
file,
`# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` +
`[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` +
`[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`,
{ secret: false }
);
const add = (pattern) =>
git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]);
await add(gitDir);
// The worktrees this workspace's sessions make (`claude --worktree=ah-<id>-…`).
await add(path.join(commonDir, "worktrees", `ah-${n}-*`));
}
/**
* 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
@@ -619,11 +753,18 @@ async function removeSshKey(account, which) {
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
);
if (!gone.length) return false;
// Commits signed with a key about to go would all fail: stop signing first.
// Commits signed with a key about to go would all fail: stop signing first —
// account-wide, and in every workspace that signs with it.
const signingKey = await globalGet(account, "user.signingkey");
if (signingKey && gone.some((k) => k.file === signingKey)) {
await setSigning(account, { id: null }).catch(() => {});
}
const goneIds = new Set(gone.map((k) => k.id));
for (const l of await laneSigningList(account)) {
if (goneIds.has(l.key)) {
await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {});
}
}
for (const k of gone) {
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
@@ -1425,6 +1566,8 @@ module.exports = {
importSshKey,
parsePrivateKey,
signingState,
laneSigningList,
setLaneSigning,
setSigning,
removeSshKey,
terminalSshCommand,
+27 -1
View File
@@ -594,7 +594,13 @@ const paths = {
summary:
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
operationId: "setCommitSigning",
requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" } }),
description:
"The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.",
requestBody: json({
key: { type: "string", nullable: true },
replace: { type: "boolean" },
clearForeign: { type: "boolean" },
}),
responses: {
200: { description: "The identity; `signing.state` is on, off or foreign." },
400: { description: "ENOKEY" },
@@ -602,6 +608,26 @@ const paths = {
},
},
},
"/api/me/git/signing/lane": {
put: {
tags: ["Git identity"],
summary:
"Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)",
description:
"Writes a small file of AgentHub's (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:<the workspace's git dir>\"` and `gitdir:<common dir>/worktrees/ah-<id>-*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.",
operationId: "setLaneCommitSigning",
requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [
"laneId",
]),
responses: {
200: { description: "The identity, with `laneSigning`." },
400: { description: "ENOKEY" },
403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." },
404: { description: "ENOTFOUND — no such workspace (or not the caller's)." },
409: { description: "ENOREPO — the workspace's folder is not a git repository." },
},
},
},
"/api/me/git/ssh-terminal": {
put: {
tags: ["Git identity"],
+51 -2
View File
@@ -14,7 +14,10 @@
* public half ever comes back), and whether commits are signed with one of
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
* set themselves is replaced only on `replace: true`). The account is the signed-in user's
* set themselves is replaced only on `replace: true`, or taken out on
* `clearForeign: true` — their key file is never touched), or — per workspace —
* which of the caller's workspaces sign (`PUT /signing/lane`, an
* `includeIf "gitdir:…"` of one small file of ours each). 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.
@@ -47,6 +50,9 @@ const STATUS = {
EBADKEY: 400,
EKEYPASSPHRASE: 400,
EFOREIGNSIGN: 409,
ENOREPO: 409,
ENOTFOUND: 404,
EFOREIGN: 403,
};
function fail(res, e) {
@@ -70,6 +76,8 @@ async function view(account, username) {
codecommitRegions: identity.CODECOMMIT_REGIONS,
https: identity.listHttps(account),
signing: await identity.signingState(account),
// Workspaces whose commits are signed: `[{laneId, key}]`.
laneSigning: await identity.laneSigningList(account),
author: await identity.getAuthor(account),
// What the author email form suggests while none is set.
defaultEmail: identity.defaultEmail(username),
@@ -148,7 +156,48 @@ router.put("/signing", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
await identity.setSigning(account, { id, replace: req.body?.replace === true });
await identity.setSigning(account, {
id,
replace: req.body?.replace === true,
clearForeign: req.body?.clearForeign === true,
});
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* Sign the commits of ONE workspace with `{laneId, key}` (`key: null` stops).
* The workspace must be one the caller may see (scopeGuard, `laneId` in the
* body) whose folder runs as this same account — a workspace another account
* runs could never read this account's `~/.gitconfig` (EFOREIGN).
*/
router.put("/signing/lane", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const laneId = Number(req.body?.laneId);
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
// Stopping touches only this account's own file, so it needs no workspace:
// one removed from the dashboard while it signed can still be turned off.
if (id == null && Number.isSafeInteger(laneId) && laneId > 0) {
await identity.setLaneSigning(account, { laneId, id: null });
return res.json(await view(account, usernameOf(req)));
}
const lane = Number.isSafeInteger(laneId) ? require("../lib/lanes").getLane(laneId) : null;
if (!lane || !require("../lib/scope").canSeeLane(req, laneId)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such workspace" } });
}
const folder = identity.accountForDir(lane.cwd);
if (
folder.kind !== account.kind ||
(folder.kind === "member" && folder.acct.login !== account.acct.login)
) {
throw Object.assign(new Error("this workspace runs as another account"), {
code: "EFOREIGN",
});
}
await identity.setLaneSigning(account, { laneId, cwd: lane.cwd, id });
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);