From 2342f54179cc401fea81f4876a1c7d0bf780d517 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 16:49:14 +0700 Subject: [PATCH] feat(git-identity): commit signing per workspace, and Delete in the signing card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.gitconfig beside the account's keys; a member's through write-as) and includes it from ~/.gitconfig with includeIf "gitdir:" and "gitdir:/worktrees/ah--*" (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. --- CLAUDE.md | 4 +- .../settings/GitIdentitySettings.tsx | 321 ++++++++++++------ .../__tests__/GitIdentitySettings.test.tsx | 128 +++++-- client/src/i18n/locales/en/settings.json | 30 +- client/src/i18n/locales/vi/settings.json | 30 +- client/src/lib/api.ts | 21 +- docs/API.md | 3 +- server/__tests__/git-signing.test.js | 130 ++++++- server/lib/git-identity.js | 149 +++++++- server/openapi-extra/files.js | 28 +- server/routes/git-identity.js | 53 ++- 11 files changed, 747 insertions(+), 150 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f73ce2fc..ba3e7994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.62 (2026-11-02) — every signed-in account sees other users' avatars._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.63 (2026-11-02) — commit signing is chosen per workspace in Settings → Git._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -46,7 +46,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. - **The dashboard's git writes are these forty-seven, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js`, `git-conflicts.js` and `git-pick.js` it dispatches to), not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s), and the History tab's `pick-continue`, `pick-skip`, `pick-abort`, `undo-pick` (`git-pick.js`) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-remove`, `pick-skip`, `pick-abort` and `undo-pick` refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open; with `shas: [...]` instead of `sha` — 1–50 non-merge commits, `git-pick.js` — they run as ONE git sequence in the order `GET /git/pick-plan` previews (a pick oldest→newest, a revert newest→oldest), a stop on a conflict is finished by `pick-continue` (`ECONFLICTS` while anything is unmerged), `pick-skip` or `pick-abort` (abort also ends a sequence whose *_HEAD is gone), none of them — nor `undo-pick` — is in `ALLOWED_WHILE_REBASING`, and the single-`sha` body stays git-ops.js's own path): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-pick` = `reset --soft ` takes back what a pick/revert just made (`confirm: true`, repo root, only while HEAD is still the `to` that pick produced — `ESTALE` —, `from` its ancestor 1–50 commits back with no merge between, none of them on a remote-tracking branch — `EPUSHED`, no override). `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log` — paged with `skip`, `all=1`, search by `q`/`author`/`path`, each commit with parents and ref badges —, `/git/commit`, `/git/patch` — a secret file's diff left out —, `/git/file-history` (`--follow`), `/git/blame-at`, `/git/tree-at` — a secret left out of the list —, `/git/compare`, `/git/pick-plan`, `/git/sequence` (`server/lib/git-history.js`, mounted by `server/routes/lane-git-history.js`, every revision resolved to a commit sha before git sees it, heavy walks timed out at 20 s; a rebase is never reported as a paused pick), `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. -- **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`), and so is commit signing (`PUT /api/me/git/signing`: `gpg.format ssh` + `user.signingkey` = one of the account's AgentHub key files + `commit.gpgsign`; a signing key the user set themselves only with `replace`). An imported key (`POST /api/me/git/ssh-key/import`) is written exactly like a generated one — the private text goes IN, only its public half comes back, a passphrase key is refused. Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`. +- **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`), and so is commit signing (`PUT /api/me/git/signing`: `gpg.format ssh` + `user.signingkey` = one of the account's AgentHub key files + `commit.gpgsign`; a signing key the user set themselves only with `replace`). An imported key (`POST /api/me/git/ssh-key/import`) is written exactly like a generated one — the private text goes IN, only its public half comes back, a passphrase key is refused. Commit signing is PER WORKSPACE (`PUT /api/me/git/signing/lane`): one small file of ours per workspace (`lane-.gitconfig` beside the account's keys) pulled in by `includeIf "gitdir:…"` lines in the account's `~/.gitconfig` for the workspace's git dir and its sessions' `ah--*` worktrees — never written into the repository, and deleting a key stops every workspace signing with it. Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config, the account's own `~/.gitconfig` or those signing files. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. - **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. - **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude` — a Grok payload, recognised by its camelCase `hookEventName` + `workspaceRoot` since Grok runs the Claude hooks, is dropped). AgentHub never reads the IDE's data. diff --git a/client/src/components/settings/GitIdentitySettings.tsx b/client/src/components/settings/GitIdentitySettings.tsx index 628a46b2..a0c46ec8 100644 --- a/client/src/components/settings/GitIdentitySettings.tsx +++ b/client/src/components/settings/GitIdentitySettings.tsx @@ -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ĩ @@ -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, done?: string) => Promise; }) { 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(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 (
setForm({ text: "", name: "", sign: true })} + onClick={() => setForm({ text: "", name: "" })} > {t("gitIdentity.importKey")} @@ -330,52 +377,138 @@ function SigningCard({

{t("gitIdentity.signIntro")}

-
- - {signing.state === "on" - ? t("gitIdentity.signOn", { key: keyLabel(signing.key) }) - : signing.state === "foreign" - ? t("gitIdentity.signForeign", { path: signing.path }) - : t("gitIdentity.signOff")} - - {keys.length > 0 && ( - + + {t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })} + + {id.writable && ( + + )} +
+ )} + + {signing.state === "foreign" && ( +
+ + {t("gitIdentity.signForeign", { path: signing.path })} + + {id.writable && ( + + )} +
+ )} + +
+
+ {t("gitIdentity.signLanes")} +
+ {usable.length === 0 ? ( +

{t("gitIdentity.signNeedKey")}

+ ) : lanes === null ? null : mine.length + orphans.length === 0 ? ( +

{t("gitIdentity.signLanesEmpty")}

+ ) : ( + [...mine, ...orphans].map((l) => { + const on = signedWith(l.id); + return ( +
+ + {laneName(l)} + + +
+ ); + }) )}
- {current && ( - -
- {t("gitIdentity.signHostStep")} - + {usable.length > 0 && ( +
+
+ {t("gitIdentity.signKeys")}
-
{current.fingerprint}
- + {usable.map((k) => { + const used = laneSigning.filter((x) => x.key === k.id).length; + return ( +
+ +
+
{keyLabel(k.id)}
+
+ {k.fingerprint} + {used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""} +
+
+ + {id.writable && ( + + )} +
+ ); + })} +

{t("gitIdentity.signHostStep")}

+
)} {form && ( @@ -407,24 +540,14 @@ function SigningCard({ onChange={(e) => setForm({ ...form, text: e.target.value })} className={`${INPUT} resize-y font-mono text-[10px]`} /> -
- setForm({ ...form, name: e.target.value.toLowerCase() })} - className={`${INPUT} !w-40 font-mono`} - /> - -
+ setForm({ ...form, name: e.target.value.toLowerCase() })} + className={`${INPUT} !w-40 font-mono`} + />

{t("gitIdentity.importNote")}