Public: anyone can read this; the crew can change it.

Outward-facing and hard-to-undo actions ask first in the conversation, and act once on a yes #7882

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7650-outward-facing-and-hard-to-undo-actions → main 8 commits fork 2 threads · 2 unresolved

  • Merged. torque/7650-outward-facing-and-hard-to-undo-actions was merged into main as 853dcfa.

Review

Sign in to review this change.
21 files changed +1080 −35
docs/MCP.md +51 −19
@@ -35,10 +35,10 @@ for a tunnel and a forge that sleeps.
3535 | `account_suspend` | `{ username, suspended }`: suspend a person's account, or unsuspend it with `false`; their sessions and tokens stop at once; the API's rules (not yourself, another admin or an agent; not the only owner of an organization); an admin token | yes |
3636 | `account_verify` | `{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token | yes |
3737 | `accounts` | `{ cursor?, limit? }`: the forge's accounts, newest first: `username`, `role`, `verified`, `suspended`, `created`, and `next` (a cursor, or null); `limit` 1-50 (default 20); a person admin's | |
38 | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
38 | `agent` | (admin scope) `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
3939 | `agents` | `{}`: the forge's agents you may see, as `GET /api/v1/agents` lists them (an agent's brief, model and budget only to the agent itself and the forge's admin); a signed-in caller | |
4040 | `app_deploy` | `{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }` | yes |
41 | `app_domains` | `{ repo, action, host? }`: `list`, `add`, `check` (looks the DNS proof up now) or `remove` an app's own domains, with the records to publish, as `…/app/domains` answers; an admin of the repository with an admin-scope token | yes |
41 | `app_domains` | (admin scope) `{ repo, action, host? }`: `list`, `add`, `check` (looks the DNS proof up now) or `remove` an app's own domains, with the records to publish, as `…/app/domains` answers; an admin of the repository with an admin-scope token | yes |
4242 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
4343 | `app_members` | `{ repo, members?, dryRun? }`: the people a private app names, who may enter it and its previews and nothing else; a writer (never an agent) changes the list with an admin-scope token | yes |
4444 | `app_notes` | `{ app, action?, note? }`: the notes sent from an app's forge button and its banned senders; `hide` a note, `ban` or `unban` its sender; an admin of the repository | yes |
@@ -54,8 +54,8 @@ for a tunnel and a forge that sleeps.
5454 | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | |
5555 | `attach_look` | `{ app, pull, width, says, image, dryRun? }`: attach a look to an open pull: a PNG or JPEG (at most 5 MB, base64 in `image`), `width` phone or desktop, one line in `says`; planned first (`dryRun` is true unless `false`) | yes |
5656 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | |
57 | `backups_destination` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }`: `set` or `forget` where snapshots go, validated as the Backups page does; answers `{ kind, place, says }`, never a key; a person admin's, with an admin token | yes |
58 | `backups_test` | `{ endpoint?, bucket?, region?, accessKey?, secretKey? }`: list the snapshot bucket as the Backups page's Test button does (blank fields test the destination in effect); a forge admin's, with an admin token; answers `{ reached, says }`, never a key | yes |
57 | `backups_destination` | (admin scope) `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }`: `set` or `forget` where snapshots go, validated as the Backups page does; answers `{ kind, place, says }`, never a key; a person admin's, with an admin token | yes |
58 | `backups_test` | (admin scope) `{ endpoint?, bucket?, region?, accessKey?, secretKey? }`: list the snapshot bucket as the Backups page's Test button does (blank fields test the destination in effect); a forge admin's, with an admin token; answers `{ reached, says }`, never a key | yes |
5959 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
6060 | `board_pins` | `{ op?, slug?, title?, by? }` (person admins): without `op`, the panels pinned to your home (`slug`, `title`); `pin` or `unpin` a panel by board `slug` and panel `title`, or `move` it up or down with `by` (-1 or 1) | yes |
6161 | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes |
@@ -96,7 +96,7 @@ for a tunnel and a forge that sleeps.
9696 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
9797 | `here` | `{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page | |
9898 | `hold` | `{ app, pull | path, why?, until?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes |
99 | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); an admin-scope token on a repository you administer, and a secret is never shown | yes |
99 | `hooks` | (admin scope) `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); an admin-scope token on a repository you administer, and a secret is never shown | yes |
100100 | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes |
101101 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | |
102102 | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes |
@@ -111,8 +111,8 @@ for a tunnel and a forge that sleeps.
111111 | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes |
112112 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, `closedPerDay` (14 days) and `paceDate`, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
113113 | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes |
114 | `mirror_now` | `{}` (forge admin, admin-scope token): brings the off-box snapshot mirror level now, as the Backups page's button does; answers `{ level, newest?, error?, says }`, `says` worded as the page's flash | yes |
115 | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes |
114 | `mirror_now` | (admin scope) `{}` (forge admin, admin-scope token): brings the off-box snapshot mirror level now, as the Backups page's button does; answers `{ level, newest?, error?, says }`, `says` worded as the page's flash | yes |
115 | `mirror_set` | (admin scope) `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes |
116116 | `model_ceiling` | `{ username, dailyUsd, perRunUsd, dryRun? }` (a forge admin who is a person): cap an account's daily and per-run model spend in dollars (both `null` clears); planned unless `dryRun` is false, applying takes an admin token | yes |
117117 | `model_grant` | `{ username, usdPerMonth, dryRun? }` (a forge admin who is a person): give an account a capped monthly model grant in dollars (0 takes it back); planned unless `dryRun` is false, applying takes an admin token | yes |
118118 | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes |
@@ -120,7 +120,7 @@ for a tunnel and a forge that sleeps.
120120 | `notify` | `{ url? }`: read (its host only), set or, with an empty `url`, turn off your phone notify URL, as Settings does; a person's, saving needs an admin token | yes |
121121 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
122122 | `org_create` | `{ name }`: make an organization you own, with the same rules and words as POST `/api/v1/orgs` (a taken, reserved or invalid name is refused; an agent cannot own one) | yes |
123 | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
123 | `org_teams` | (admin scope) `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
124124 | `orgs` | `{ org? }`: without `org`, the organizations you belong to; with it, the organization, the teams you may see and each team's members; the same as GET `/api/v1/user/orgs`, `/api/v1/orgs/:org`, `…/teams` and `…/members` | |
125125 | `origin_apps` | `{ action, id?, host?, kind?, clientId?, clientSecret? }`: `list` your OAuth apps for connecting origins, `add` one (a `host`, or `kind` github), or `remove` one by `id`; the API's rules and errors, an admin-scope token; a secret is never read back, only `hasSecret` | yes |
126126 | `origins` | `{ action, id?, hosts? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), `sync` one now, or `hosts`: with no `hosts`, read the internal hosts an origin may live on (a forge admin, `fromEnv` read-only), with `hosts` (one per line) replace them as Settings → Origins does, a rejected line named in the error; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes |
@@ -132,7 +132,7 @@ for a tunnel and a forge that sleeps.
132132 | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | |
133133 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
134134 | `profile` | `{ displayName?, email?, commitEmail? }`: your own profile (`publicUser` and `emailVerified`, never the address); with an argument it changes it by the rules of `PATCH /api/v1/user`, and needs admin scope: only a site admin can mint such a token, so most people sign in to `/mcp` with their password | yes |
135 | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
135 | `prompts` | (admin scope) `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
136136 | `proposal_tokens` | `{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only | yes |
137137 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
138138 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
@@ -140,18 +140,18 @@ for a tunnel and a forge that sleeps.
140140 | `queue_gates` | `{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky` | |
141141 | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
142142 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says} | |
143 | `release_hold` | `{ reason }` or `{ lift }`: hold the release keeper (#7557) with a reason, so it cuts nothing until the hold is lifted; or lift an owner hold by its id. A failed verify's hold is lifted by a verified release after a pull saying `release hold N` lands, not here. A person who may roll back, with an admin-scope token, never an agent | yes |
143 | `release_hold` | (admin scope) `{ reason }` or `{ lift }`: hold the release keeper (#7557) with a reason, so it cuts nothing until the hold is lifted; or lift an owner hold by its id. A failed verify's hold is lifted by a verified release after a pull saying `release hold N` lands, not here. A person who may roll back, with an admin-scope token, never an agent | yes |
144144 | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | |
145145 | `releases` | `{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
146146 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
147147 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
148 | `repo_settings` | `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
148 | `repo_settings` | (admin scope) `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
149149 | `report` | `{ repo, title, body?, page?, agent? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text. With a token the report carries your account; `agent` without one is shown as a claim | yes |
150150 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` with `filedBy` (`signed`, `claimed` or `anonymous`) and `untrusted` unless a writer filed it, and `byAgent` counts; with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
151151 | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes |
152152 | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | |
153153 | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes |
154 | `rollback` | `{ sha, dryRun? }`: roll the forge back to a kept release, as /releases does; plans unless `dryRun` is false (from which release to which, and whether the live schema allows it); a person who may roll back, with an admin-scope token, never an agent | yes |
154 | `rollback` | (admin scope) `{ sha, dryRun? }`: roll the forge back to a kept release, as /releases does; plans unless `dryRun` is false (from which release to which, and whether the live schema allows it); a person who may roll back, with an admin-scope token, never an agent | yes |
155155 | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | |
156156 | `runs` | `{ repo, kind?, limit? }`: the repository's recent runs (kind agent, deploy, check or proof — a proof is the full-suite run before the merge queue), newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
157157 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
@@ -159,7 +159,7 @@ for a tunnel and a forge that sleeps.
159159 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded; with `action: "private"` instead of `sealed`, the private repository a writer fixes a sealed report in, made on first need, with its name and clone URL (the API's `POST …/issues/:number/private`, same rules) | yes |
160160 | `search` | `{ q, kind?, repo?, all? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one; `moreCode` says the code grep stopped at its budget, `all` greps the rest | |
161161 | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes |
162 | `sessions` | `{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: "end"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals | yes |
162 | `sessions` | (admin scope) `{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: "end"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals | yes |
163163 | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes |
164164 | `share` | `{ item, to? }`: share a feed item (`item` is its id); with no `to` it goes to the app's crew and owners, else to up to 10 named usernames' followers; a daily budget each; answers `{ sent }` | yes |
165165 | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes |
@@ -170,18 +170,18 @@ for a tunnel and a forge that sleeps.
170170 | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes |
171171 | `steer` | `{ repo, run, text }`: say a word to one running crew run by its id, as the run page's steer box and `POST …/runs/:id/steer` do; a writer's (never an agent), the run an agent's, `text` not empty; answers `{ says, outcome }` (`sent` or `commented`) | yes |
172172 | `stop` | stop one run by its id from `now` | yes |
173 | `subscription_add` | `{ name, token, apiBase? }` (forge admin): put a Claude account in the crew's pool, as the Subscriptions page does, with its refusals; answers `{ id, name, says }`; the token is never read back | yes |
174 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
175 | `subscription_delete` | `{ id }`: remove a subscription and its grants, as the Subscriptions page does; runs it paid for keep it in their history; a forge admin's, with an admin token | yes |
176 | `subscription_headroom` | `{ id, percent }`: how much of each 5-hour and weekly window the crew leaves for people on one account, a whole percent, as the Subscriptions page does; answers `{ id, percent, says }`; a forge admin with an admin token | yes |
177 | `subscription_wake` | `{ id }`: use a resting Claude subscription again now, as the Subscriptions page's Wake button does; answers `{ id, says }`; a forge admin, an admin token | yes |
173 | `subscription_add` | (admin scope) `{ name, token, apiBase? }` (forge admin): put a Claude account in the crew's pool, as the Subscriptions page does, with its refusals; answers `{ id, name, says }`; the token is never read back | yes |
174 | `subscription_budget` | (admin scope) `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
175 | `subscription_delete` | (admin scope) `{ id }`: remove a subscription and its grants, as the Subscriptions page does; runs it paid for keep it in their history; a forge admin's, with an admin token | yes |
176 | `subscription_headroom` | (admin scope) `{ id, percent }`: how much of each 5-hour and weekly window the crew leaves for people on one account, a whole percent, as the Subscriptions page does; answers `{ id, percent, says }`; a forge admin with an admin token | yes |
177 | `subscription_wake` | (admin scope) `{ id }`: use a resting Claude subscription again now, as the Subscriptions page's Wake button does; answers `{ id, says }`; a forge admin, an admin token | yes |
178178 | `subscriptions` | the Claude accounts the crew works on: id (the handle `subscription_wake`, `subscription_budget` and `subscription_delete` take), name, kind, the repositories and organizations each pays for, budget and spend, and whether it is resting; never a token. A forge admin's | |
179179 | `sync` | `{ repo, originId? }`: sync a mirror now, or with `originId` (admin token) reconnect its origin; refused as `POST …/sync` and `…/origin/reconnect` are, a fork is pointed to `take_updates` | yes |
180180 | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | |
181181 | `take_back` | `{ repo, number }`: open the pull that undoes shipped pull `number` (branch `revert-N`, title `Revert "…"`), never touching the default branch; the same rules and words as the pull page's take it back: refused for a pull not shipped (`not_shipped`), already taken back (`already_reverted`), on a mirror (`mirrored`), a revert that does not apply (`conflict`), an agent (`forbidden`) and a pull the caller cannot see (`not_found`); answers the new pull's `number`, `title` and `url` | yes |
182182 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes |
183183 | `teams` | `{ slug?, charter?, dial?, members?, dryRun? }`: the crew teams (person admins): without `slug` lists them; with it, changes charter, dial (0–100) or members, planned unless `dryRun` is false | yes |
184 | `telemetry_grant` | `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes |
184 | `telemetry_grant` | (admin scope) `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes |
185185 | `telemetry_history` | `{ hours? }` (1–72, default 6): the node's and the forge's CPU cores, memory and load over the window, `{ hours, points: [{ target, at, cpuCores, cpuCoresMax (the bucket's peak), memoryBytes, load }] }`, at most 300 points a series (15-s samples to 24 h, 5-min beyond, averaged down); a forge admin or `telemetry:read`, as `system`; the machine records no disk history | |
186186 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
187187 | `templates` | the templates a child forge may start from (`blank` first, the default), each with id, title, one sentence and source; pass an id as `spawn`'s `template` | |
@@ -234,6 +234,38 @@ a failure names the bytes over and the longest descriptions.
234234 Answers are JSON in a text block; most carry a `says` sentence short enough
235235 to relay as it is.
236236
237 ### Asked first in the conversation
238
239 A tool marked `(admin scope)` is listed and called only with an admin-scope
240 token. An admin whose token is short of it reads, on the first `tools/list`
241 page's `_meta.hidden` and in `initialize`'s instructions, one sentence naming
242 the hidden tools and the two ways to them: an admin-scope token, or a confirm
243 in the conversation (#7650).
244
245 The forge's own agent (a via token) asks before an outward-facing or
246 hard-to-undo action: release and rollback, archive and delete, subscriptions
247 and budgets, secrets, invites, SSH keys, and every admin-scope tool — the one
248 list is `ASKING` in `src/crew/conversation_confirm.ts`. Such a call does
249 nothing and answers `{ needs_confirm, tool, does, says }`: `does` is one
250 sentence of what it will do, said to the person; a secret's value is never
251 repeated in it, and an argument cannot break it onto a new line. A tool whose
252 only reading action is `list` (`secrets`, `app_env`, `ssh_keys`, and
253 `proposal_tokens` by its `verb`) asks for its writes only. A `confirm` turn is
254 written to their conversation.
255
256 The person says yes from their own browser session, same-origin, never a
257 token, so never the agent: Settings → Confirms (`/settings/confirms`), the
258 terminal's Confirms (`y`), or `POST /api/v1/conversation-confirms/:id`. All
259 three call `sayYes` in `src/actions/conversation_confirms.ts`. It does the
260 stored call with its stored arguments once, as the person via the forge's
261 agent, and writes a `tool` turn naming the undo when the action has one. A
262 result shown only once (`mint_invite`'s link, `claim_link`, a minted proposal
263 token) is answered to the person and kept in the turn only as a sentence. A
264 second yes is 409 `answered`; a yes 10 minutes on is 410 `expired`. A confirm
265 left unanswered for 10 minutes is swept: its arguments are cleared and it no
266 longer counts toward the 50 a conversation may hold. An admin-scope tool is
267 reached this way only by a person who is an admin.
268
237269 ## Arguments, in full
238270
239271 `tools/list` keeps every argument description to 60 bytes (`test/mcp-size.test.ts`
docs/design/audit.md +1
@@ -118,6 +118,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
118118 - `/settings/tokens` — `pages/account.ts` · my API tokens · `/settings/keys` (credentials of two kinds on two pages) · colour 1.
119119 - `/settings/keys` — `pages/account.ts` · my SSH keys · `/settings/tokens` · colour 1.
120120 - `/settings/passkeys` — `pages/passkeys.ts` · my passkeys · `/settings/password` · none seen.
121 - `/settings/confirms` — `pages/confirms.ts` · what the forge's agent asked to do and waits for my yes · the terminal's Confirms (y) · none seen.
121122 - `/settings/secrets` — `pages/secrets.ts` · secrets apps may read · none · none seen.
122123 - `/settings/subscriptions` — `pages/subscriptions.ts` (624 lines) · the model subscriptions the crew spends from, with spend tables (`pages/spend.ts`) · `/board/returns` · none seen.
123124 - `/settings/accounts` — `pages/accounts.ts` · every account on the forge · `/settings/people` · none seen.
scripts/mcp-tools.ts +8 −2
@@ -40,13 +40,19 @@ export function indexText(names: readonly string[]): string {
4040 ].join("\n");
4141 }
4242
43 type Documented = { readonly name: string; readonly doc?: string; readonly writes: boolean };
43 type Documented = {
44 readonly name: string;
45 readonly doc?: string;
46 readonly writes: boolean;
47 readonly needsAdminScope?: true;
48 };
4449
4550 export function tableText(tools: readonly Documented[]): string {
4651 const rows = [...tools]
4752 .filter((t) => t.doc !== undefined)
4853 .sort(byName)
49 .map((t) => `| \`${t.name}\` | ${t.doc} | ${t.writes ? "yes" : ""} |`);
54 // An admin-scope tool is marked so, as `tools/list` hides it from a lesser token (#7650).
55 .map((t) => `| \`${t.name}\` | ${t.needsAdminScope === true ? "(admin scope) " : ""}${t.doc} | ${t.writes ? "yes" : ""} |`);
5056 return [BEGIN, "", "| tool | does | writes |", "| ---- | ---- | ------ |", ...rows, "", END].join("\n");
5157 }
5258
src/actions/conversation_confirms.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The person's yes (#7650), one action for every door: the API
3 * (`http/api/conversation_confirms.ts`), Settings → Confirms
4 * (`web/pages/confirms.ts`) and the terminal (`tui/screens/confirms.ts`).
5 * It does a call the forge's agent was told to ask first
6 * (`crew/conversation_confirm.ts`) or that the fence held (`core/fence.ts`),
7 * with its stored arguments, exactly once: the confirm is claimed before the
8 * call runs. The call is on the trail as the person by way of the agent, and
9 * a `tool` turn in their conversation names the undo when there is one.
10 *
11 * Who is asking is decided at each door — the person's own session, never a
12 * token — so this module takes a person who already passed it.
13 */
14
15 import { actingVia } from "../core/acting_via.ts";
16 import { appendTurn, MAX_TURN_CHARS, openConversation } from "../core/conversations.ts";
17 import { answerHeld, HELD_TTL_MS, waitingFor, type YesRefusal } from "../core/fence.ts";
18 import type { User } from "../core/users.ts";
19 import { confirmSentence, keptOf, undoOf } from "../crew/conversation_confirm.ts";
20 import type { Ctx } from "../http/context.ts";
21 import { TOOLS } from "../http/mcp/tools.ts";
22
23 /** A confirm as the person reads it before saying yes. */
24 export interface Waiting {
25 readonly id: string;
26 readonly tool: string;
27 readonly does: string;
28 readonly createdAt: string;
29 readonly expiresAt: string;
30 }
31
32 /** The person's confirms still waiting, oldest first; none past their 10 minutes. */
33 export function waitingConfirms(ctx: Pick<Ctx, "app">, user: User, now: number = Date.now()): Waiting[] {
34 return waitingFor(ctx.app.db, user.id, now).map((h) => ({
35 id: h.id,
36 tool: h.tool,
37 does: confirmSentence(h.tool, h.args),
38 createdAt: h.createdAt,
39 expiresAt: new Date(Date.parse(h.createdAt) + HELD_TTL_MS).toISOString(),
40 }));
41 }
42
43 export const CONFIRM_ID = /^[0-9a-z]{1,40}$/;
44
45 /** Why a yes did nothing, in words, with the status the API answers. */
46 export const REFUSED: Readonly<Record<YesRefusal | "forbidden", readonly [number, string]>> = {
47 not_found: [404, "no such confirm of yours"],
48 answered: [409, "this confirm was already answered; it does nothing twice"],
49 expired: [410, "this confirm is past its 10 minutes; ask the agent again"],
50 forbidden: [403, "this action is not yours to take"],
51 };
52
53 /** What a yes did: `turn` as the conversation keeps it, `result` as the person sees it once. */
54 export type Yes =
55 | { ok: false; error: YesRefusal | "forbidden" }
56 | { ok: true; done: boolean; turn: Record<string, unknown>; result?: unknown };
57
58 /** The result as the turn keeps it: whole when it fits, cut when it would not. */
59 const kept = (value: unknown): unknown => {
60 const text = JSON.stringify(value) ?? "null";
61 return text.length <= MAX_TURN_CHARS / 2 ? value : `${text.slice(0, MAX_TURN_CHARS / 2)}…`;
62 };
63
64 /** Say yes to `confirmId` as `user`, whose own session asked. */
65 export async function sayYes(ctx: Ctx, user: User, confirmId: string): Promise<Yes> {
66 if (!CONFIRM_ID.test(confirmId)) return { ok: false, error: "not_found" };
67 const answered = answerHeld(ctx.app.db, confirmId, user.id);
68 if (!answered.ok) return answered;
69 const held = answered.value;
70 const tool = TOOLS.find((t) => t.name === held.tool);
71 // What the person may no longer do is not done on an old yes.
72 if (tool === undefined || ((tool.adminOnly === true || tool.needsAdminScope === true) && !user.isAdmin))
73 return { ok: false, error: "forbidden" };
74 // The session's own authority, as a token would carry it: admin scope
75 // for an admin only, as only an admin mints an admin token.
76 const asPerson: Ctx = { ...ctx, auth: { ...ctx.auth, user, scope: user.isAdmin ? "admin" : "rw" } };
77 const ran = await actingVia({ userId: user.id, via: held.via }, () =>
78 Promise.resolve(tool.run(asPerson, user, held.args)),
79 );
80 const undo = ran.ok ? undoOf(held.tool, held.args) : undefined;
81 const turn = {
82 confirm: held.id,
83 tool: held.tool,
84 done: ran.ok,
85 ...(ran.ok ? { result: kept(keptOf(held.tool, ran.value)) } : { error: ran.error, message: ran.message }),
86 ...(undo === undefined ? {} : { undo }),
87 };
88 const conversation = openConversation(ctx.app.db, user.id);
89 appendTurn(ctx.app.db, user.id, conversation.id, { role: "tool", text: JSON.stringify(turn) });
90 return { ok: true, done: ran.ok, turn, ...(ran.ok ? { result: ran.value } : {}) };
91 }
src/contract/openapi/routes/index.ts +1
@@ -187,6 +187,7 @@ export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts";
187187 export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts";
188188 export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
189189 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
190 export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts";
190191 export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts";
191192 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
192193 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
src/contract/openapi/routes/postApiV1ConversationConfirmsId.ts added +14
@@ -0,0 +1,14 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/conversation-confirms/{id}",
6 summary: "The person's yes to a confirm the forge's agent asked in their conversation: does the stored call, with its stored arguments, exactly once, as the person by way of the agent; only the person's own browser session on the same origin says yes (a bearer token or password is 401); answers the result, the `tool` turn it wrote, with `undo` when the action has one",
7 auth: false,
8 refusals: {
9 not_found: "no such confirm of the person's",
10 answered: "the confirm was already answered (409)",
11 expired: "the confirm is 10 minutes old or more (410)",
12 forbidden: "the request is cross-origin, or the action is no longer the person's to take",
13 },
14 };
src/core/fence.ts +100 −10
@@ -202,10 +202,26 @@ export interface Held {
202202 /** A conversation keeps at most this many held writes; past it, a write is refused unkept. */
203203 export const HELD_MAX = 50;
204204
205 /** Ten minutes: a held write not answered by then is past its time, and its arguments are cleared. */
206 export const HELD_TTL_MS = 600_000;
207
208 /**
209 * Put every held write past `HELD_TTL_MS` out of reach (#7650): stamped
210 * answered and its arguments cleared, so a secret it carried is not kept at
211 * rest and it no longer counts toward `HELD_MAX`. Run before a write is held
212 * and before the person's confirms are listed or answered.
213 */
214 export function sweepHeld(db: Database, now: number = Date.now()): void {
215 db.query(
216 "UPDATE conversation_held SET answered_at = ?1, args = '{}' WHERE answered_at IS NULL AND created_at <= ?2",
217 ).run(new Date(now).toISOString(), new Date(now - HELD_TTL_MS).toISOString());
218 }
219
205220 /**
206221 * Keep a held write as a confirm the person answers; its id, or null when
207 * the conversation already holds `HELD_MAX` (a steered agent cannot fill
208 * the table). Counted and kept in one statement.
222 * the conversation already holds `HELD_MAX` waiting (a steered agent cannot
outdated

Expired but unanswered rows still count toward HELD_MAX and never go away, so the agent can be locked out of confirms, and their args (secrets) stay at rest. Count/sweep by age: clear args and stamp answered_at for rows older than the TTL.

223 * fill the table). Those past their time are swept first, so they never
224 * count; counted and kept in one statement.
209225 */
210226 export function hold(
211227 db: Database,
@@ -213,16 +229,20 @@ export function hold(
213229 userId: string,
214230 tool: string,
215231 args: Record<string, unknown>,
232 now: number = Date.now(),
216233 ): string | null {
217234 const heldId = id();
218 const kept = db
219 .query(
220 `INSERT INTO conversation_held (id, via, user_id, tool, args, created_at)
221 SELECT ?1, ?2, ?3, ?4, ?5, ?6
222 WHERE (SELECT COUNT(*) FROM conversation_held WHERE via = ?2) < ?7`,
223 )
224 .run(heldId, via, userId, tool, JSON.stringify(args), new Date().toISOString(), HELD_MAX);
225 return kept.changes === 1 ? heldId : null;
235 return db.transaction(() => {
236 sweepHeld(db, now);
237 const kept = db
238 .query(
239 `INSERT INTO conversation_held (id, via, user_id, tool, args, created_at)
240 SELECT ?1, ?2, ?3, ?4, ?5, ?6
241 WHERE (SELECT COUNT(*) FROM conversation_held WHERE via = ?2 AND answered_at IS NULL) < ?7`,
242 )
243 .run(heldId, via, userId, tool, JSON.stringify(args), new Date(now).toISOString(), HELD_MAX);
244 return kept.changes === 1 ? heldId : null;
245 })();
226246 }
227247
228248 /** A conversation's held writes, oldest first. */
@@ -242,3 +262,73 @@ export function heldIn(db: Database, via: string): Held[] {
242262 createdAt: r.created_at,
243263 }));
244264 }
265
266 /** Why a yes is refused: no such confirm of this person's, already answered, or past its time. */
267 export type YesRefusal = "not_found" | "answered" | "expired";
268
269 /** A person's confirms still waiting for their yes, oldest first; those past their time are swept first. */
270 export function waitingFor(db: Database, userId: string, now: number = Date.now()): Held[] {
271 sweepHeld(db, now);
272 return db
273 .query<
274 { id: string; via: string; user_id: string; tool: string; args: string; created_at: string },
275 [string]
276 >(
277 "SELECT id, via, user_id, tool, args, created_at FROM conversation_held WHERE user_id = ?1 AND answered_at IS NULL ORDER BY id",
278 )
279 .all(userId)
280 .map((r) => ({
281 id: r.id,
282 via: r.via,
283 userId: r.user_id,
284 tool: r.tool,
285 args: JSON.parse(r.args) as Record<string, unknown>,
286 createdAt: r.created_at,
287 }));
288 }
289
290 /**
291 * The person's yes to a held write, claimed once (#7650): the row is
292 * stamped answered and its arguments cleared in the same transaction that
293 * reads them, so a second yes finds it answered. Only the person it was
294 * held for may answer; another person's confirm is `not_found`. A yes
295 * `HELD_TTL_MS` or more after it was held is `expired` — whether this yes
296 * or a sweep stamped it.
297 */
298 export function answerHeld(
299 db: Database,
300 heldId: string,
301 userId: string,
302 now: number = Date.now(),
303 ): { ok: true; value: Held } | { ok: false; error: YesRefusal } {
304 return db.transaction(() => {
305 sweepHeld(db, now);
306 const row = db
307 .query<
308 { id: string; via: string; user_id: string; tool: string; args: string; created_at: string; answered_at: string | null },
309 [string, string]
310 >("SELECT * FROM conversation_held WHERE id = ?1 AND user_id = ?2")
311 .get(heldId, userId);
312 if (row === null) return { ok: false as const, error: "not_found" as const };
313 if (row.answered_at !== null)
314 return {
315 ok: false as const,
316 error: Date.parse(row.answered_at) - Date.parse(row.created_at) >= HELD_TTL_MS ? ("expired" as const) : ("answered" as const),
317 };
318 db.query("UPDATE conversation_held SET answered_at = ?2, args = '{}' WHERE id = ?1").run(
319 heldId,
320 new Date(now).toISOString(),
321 );
322 return {
323 ok: true as const,
324 value: {
325 id: row.id,
326 via: row.via,
327 userId: row.user_id,
328 tool: row.tool,
329 args: JSON.parse(row.args) as Record<string, unknown>,
330 createdAt: row.created_at,
331 },
332 };
333 })();
334 }
src/core/migration-pins/20261102120000-conversation-confirm.pin added +1
@@ -0,0 +1,1 @@
1 ca9d255fd75392ba9abfb17c9afd5a87ead52d4b1c943aeb7b0c363abc54f5e5
src/core/migrations/20261102120000-conversation-confirm.ts added +9
@@ -0,0 +1,9 @@
1 // A held write is answered once (#7650): the person's yes stamps it, and a
2 // second yes finds it stamped. Its arguments are cleared on the yes, and by
3 // the sweep once it is ten minutes old unanswered, so a secret it carried
4 // is not kept past its time; the index keeps that sweep and the person's
5 // list of what still waits to the waiting rows alone.
6 export default `
7 ALTER TABLE conversation_held ADD COLUMN answered_at TEXT;
8 CREATE INDEX conversation_held_waiting ON conversation_held (user_id, created_at) WHERE answered_at IS NULL;
9 `;
src/crew/conversation_confirm.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * What the forge's agent asks before doing (#7650): outward-facing and
3 * hard-to-undo actions — release and rollback, archive and delete, money,
4 * secrets, tokens, SSH keys, and every admin-scope tool — are kept as a
5 * confirm in the person's conversation and done only on their yes, a
6 * same-origin POST from their own session (`http/api.ts`). One list, so a
7 * reviewer reads in one place what the agent may never do on its own.
8 *
9 * Plain data in, plain data out: the door that holds a call is
10 * `http/mcp/shared.ts` (`askedFirst`), the store is `core/fence.ts`.
11 */
12
13 import { HELD_TTL_MS } from "../core/fence.ts";
14
15 type Args = Record<string, unknown>;
16
17 export interface Asking {
18 /** What it does, as the start of a sentence: "roll the forge back to a kept release". */
19 readonly does: string;
20 /** Which calls ask; absent, every call that writes. */
21 readonly asks?: (args: Args) => boolean;
22 /** How it is undone, when it can be: said on the result turn. */
23 readonly undo?: (args: Args) => string | undefined;
24 /** Set when its result is shown once and never kept (a link, a credential): what the turn says instead. */
25 readonly once?: string;
26 }
27
28 /** Ten minutes: a yes later than this is refused, and the agent asks again. */
29 export const CONFIRM_TTL_MS = HELD_TTL_MS;
30
31 /** Asks unless `arg` (absent: the tool's default, `reads[0]`) is one of `reads`. */
32 const not =
33 (arg: string, ...reads: string[]) =>
34 (args: Args) =>
35 !reads.includes(String(args[arg] ?? reads[0]));
36
37 /** Every tool that asks first, by name. A tool with `needsAdminScope` must be here (test/conversation-confirm.test.ts). */
38 export const ASKING: Readonly<Record<string, Asking>> = {
39 // Release and rollback.
40 rollback: { does: "roll the forge back to a kept release", undo: () => "`rollback` to the release that was live rolls it forward again" },
41 release_hold: {
42 does: "hold or lift the release keeper",
43 undo: (a) => (a["reason"] !== undefined ? "`release_hold` with `lift` and the hold's id lifts it" : undefined),
44 },
45 // Archive and delete.
46 archive: {
47 does: "archive or wake a repository",
48 undo: (a) => `\`archive\` with \`archived: ${a["archived"] === false}\` turns it back`,
49 },
50 board_delete: { does: "delete a board" },
51 subscription_delete: { does: "remove a Claude subscription and its grants" },
52 // Money: subscriptions, budgets, grants and limits.
53 subscription_add: { does: "add a Claude login to the crew's pool", undo: () => "`subscription_delete` with its id removes it" },
54 subscription_budget: { does: "set a subscription's lifetime budget", undo: () => "`subscription_budget` with the old `usd` sets it back" },
55 subscription_headroom: { does: "set the share of each window the crew leaves for people" },
56 subscription_wake: { does: "use a resting subscription again now" },
outdated

proposal_tokens uses verb (see docs/MCP.md), but not() reads args.action, so mint/revoke are treated as list and do not ask. Fix with a predicate on verb.

57 model_grant: { does: "give an account a monthly model grant", undo: () => "`model_grant` with `usdPerMonth: 0` takes it back" },
58 model_ceiling: { does: "cap an account's model spend", undo: () => "`model_ceiling` with both `null` clears it" },
59 limits: { does: "change one of the forge's limits", undo: () => "`limits` with `value: \"\"` restores the default" },
60 // Secrets and credentials.
61 secrets: { does: "set or remove a sealed secret", asks: not("action", "list") },
62 app_env: { does: "set or unset an app's variable", asks: not("action", "list") },
63 proposal_tokens: {
64 does: "mint or revoke a child forge's propose-only credential",
65 asks: not("verb", "list"),
66 once: "The credential was shown once, to you, and is not kept here.",
67 },
68 mint_invite: { does: "mint a single-use invite link", once: "The invite link was shown once, to you, and is not kept here." },
69 claim_link: {
70 does: "make a new claim link for a child forge, ending the old one",
71 once: "The claim link was shown once, to you, and is not kept here.",
72 },
73 ssh_keys: {
74 does: "add or remove one of your SSH keys",
75 asks: not("action", "list"),
76 undo: (a) => (a["action"] === "add" ? "`ssh_keys` with `action: \"remove\"` and its id removes it" : undefined),
77 },
78 // Every admin-scope tool.
79 agent: { does: "create, change or remove a crew agent" },
80 app_domains: { does: "add, check or remove an app's domain" },
81 backups_destination: { does: "set or forget where snapshots go" },
82 backups_test: { does: "test the snapshot destination" },
83 hooks: { does: "add or remove a repository's webhook" },
84 mirror_now: { does: "bring the off-box snapshot mirror level now" },
85 mirror_set: { does: "set or forget the off-box snapshot mirror" },
86 org_teams: { does: "create, fill or remove an organization's team" },
87 prompts: { does: "add, run or remove an app's prompt source" },
88 repo_settings: {
89 does: "change a repository's description or visibility",
90 undo: () => "`repo_settings` with the old values sets them back",
91 },
92 sessions: { does: "list your sign-ins or sign a browser out" },
93 telemetry_grant: {
94 does: "grant or take back an agent's telemetry:read",
95 undo: (a) => `\`telemetry_grant\` with \`on: ${a["on"] === false}\` turns it back`,
96 },
97 };
98
99 /** Whether this call of `tool` asks first, scope aside. */
100 export function asksFirst(tool: string, args: Args): boolean {
101 const asking = ASKING[tool];
102 return asking !== undefined && (asking.asks?.(args) ?? true);
103 }
104
105 /** Argument names whose values are never repeated in a sentence or turn. */
106 const SECRET = /token|secret|key|value|password/i;
107
108 /** Control, format and line-breaking characters: an argument the agent wrote cannot start a new line or reorder the sentence. */
109 const UNSAID = /[\p{Cc}\p{Cf}\u2028\u2029]+/gu;
110
111 /** A string as said back: on one line, plain, cut to 60. */
112 const plain = (v: string): string => {
113 const one = v.replace(UNSAID, " ").trim();
114 return one.length > 60 ? `${one.slice(0, 60)}…` : one;
115 };
116
117 /** One argument as said back: scalars as written (strings plain, cut to 60), a secret hidden, anything else counted. */
118 function said(k: string, v: unknown): string {
119 const name = plain(k);
120 if (SECRET.test(k) && k !== "id") return `${name} (hidden)`;
121 if (typeof v === "string") return `${name} ${plain(v)}`;
122 if (typeof v === "number" || typeof v === "boolean" || v === null) return `${name} ${String(v)}`;
123 return Array.isArray(v) ? `${name} (${v.length})` : `${name} (…)`;
124 }
125
126 /** The one sentence the person reads before saying yes. */
127 export function confirmSentence(tool: string, args: Args): string {
128 const does = ASKING[tool]?.does ?? `call \`${tool}\``;
129 const given = Object.entries(args)
130 .filter(([k]) => k !== "dryRun")
131 .map(([k, v]) => said(k, v));
132 return `This will ${does} (\`${tool}\`${given.length === 0 ? "" : `: ${given.join(", ")}`}) as you, once you say yes within 10 minutes.`;
133 }
134
135 /** Undos of writes that do not ask first but may be held by the fence and done on a yes. */
136 const UNDONE: Readonly<Record<string, (args: Args) => string | undefined>> = {
137 close: (a) => (a["state"] === "open" ? undefined : "`close` with `state: \"open\"` reopens it"),
138 merge: () => "`take_back` opens the pull that reverts it",
139 };
140
141 /** What the result turn keeps of a done call: a sentence in place of a result shown only once. */
142 export const keptOf = (tool: string, value: unknown): unknown => ASKING[tool]?.once ?? value;
143
144 /** How a done call is undone, or undefined when it has no undo. */
145 export const undoOf = (tool: string, args: Args): string | undefined =>
146 ASKING[tool]?.undo?.(args) ?? UNDONE[tool]?.(args);
src/http/api.ts +2
@@ -324,6 +324,7 @@ import { appNoteRoutes } from "./api/app_notes.ts";
324324 import { reportRoutes } from "./api/reports.ts";
325325 import { rollbackRoutes } from "./api/rollback.ts";
326326 import { takeBackRoutes } from "./api/take_back.ts";
327 import { conversationConfirmRoutes } from "./api/conversation_confirms.ts";
327328 import { orgRoutes } from "./api/orgs.ts";
328329 import { promptRoutes } from "./api/prompts.ts";
329330 import { secretRoutes } from "./api/secrets.ts";
@@ -1871,6 +1872,7 @@ export function apiRoutes(router: Router<Handler>): void {
18711872 appNoteRoutes(router);
18721873 rollbackRoutes(router);
18731874 takeBackRoutes(router);
1875 conversationConfirmRoutes(router);
18741876
18751877 // Which stage of the one flow each open issue is in, and what went live in
18761878 // the last day (src/flow).
src/http/api/conversation_confirms.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The person's yes over the API (#7650): `POST /api/v1/conversation-confirms/:id`.
3 * Only the person's own browser session on the same origin says yes — never
4 * a token, so never the agent. What a yes does is `actions/conversation_confirms.ts`;
5 * the answer is the `tool` turn it wrote, with the result as the person sees
6 * it once (a link or credential shown once is not kept in the turn).
7 */
8
9 import { REFUSED, sayYes } from "../../actions/conversation_confirms.ts";
10 import { crossOrigin, type Handler } from "../context.ts";
11 import { apiError, forbidden, json, unauthorized } from "../respond.ts";
12 import type { Router } from "../router.ts";
13
14 export function conversationConfirmRoutes(router: Router<Handler>): void {
15 router.add("POST", "/api/v1/conversation-confirms/:id", async (ctx) => {
16 const user = ctx.auth.user;
17 if (ctx.auth.via !== "session" || user === undefined) return unauthorized();
18 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
19 const yes = await sayYes(ctx, user, ctx.params["id"] ?? "");
20 if (!yes.ok) {
21 const [status, words] = REFUSED[yes.error];
22 return apiError(yes.error, status, words);
23 }
24 return json({ ...yes.turn, ...("result" in yes ? { result: yes.result } : {}) }, yes.done ? 200 : 422);
25 });
26 }
src/http/mcp.ts +19 −4
@@ -32,7 +32,7 @@ import { fileReport } from "../actions/reports.ts";
3232 import { REPORT_RECEIVED } from "../actions/report_words.ts";
3333 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
3434 import { everydayOf } from "../core/everyday.ts";
35 import { type Args, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite } from "./mcp/shared.ts";
35 import { type Args, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst } from "./mcp/shared.ts";
3636 import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts";
3737 import { TOOLS } from "./mcp/tools.ts";
3838
@@ -59,6 +59,16 @@ function adminNote(ctx: Ctx, user: User): string {
5959 }`;
6060 }
6161
62 /**
63 * Said to an admin whose token is short of admin scope (#7650): which tools
64 * it hides, and the two deliberate ways to them. Empty for anyone else.
65 */
66 export function hiddenAdminNote(ctx: Ctx, user: User): string {
67 if (!user.isAdmin || scopeAllows(ctx.auth.scope ?? "ro", "admin")) return "";
68 const hidden = TOOLS.filter((t) => t.needsAdminScope === true).map((t) => t.name);
69 return `Hidden from this token, as admin-scope tools: ${hidden.join(", ")}; reach them with an admin-scope token, or as the forge's agent by a confirm the person answers in the conversation.`;
70 }
71
6272 /** A card's model beside the text, for a host that draws `ui://` resources. */
6373 function cardResult(
6474 name: string,
@@ -97,13 +107,14 @@ function rpcOne(
97107 switch (m["method"]) {
98108 case "initialize": {
99109 const asked = str(params["protocolVersion"]);
110 const hidden = hiddenAdminNote(ctx, user);
100111 return reply({
101112 protocolVersion: (PROTOCOLS as readonly string[]).includes(asked)
102113 ? asked
103114 : PROTOCOLS[0],
104115 capabilities: { tools: {}, resources: {} },
105116 serverInfo: server(),
106 instructions: `This is a person's forge: their apps, and the crew that builds them. \`tell\` and \`say\` are how they direct it. Keep what you relay short — three sentences and the real options. A tool with \`dryRun\` plans unless it is \`false\`.${adminNote(ctx, user)}${
117 instructions: `This is a person's forge: their apps, and the crew that builds them. \`tell\` and \`say\` are how they direct it. Keep what you relay short — three sentences and the real options. A tool with \`dryRun\` plans unless it is \`false\`.${adminNote(ctx, user)}${hidden === "" ? "" : ` ${hidden}`}${
107118 ctx.auth.through === undefined ? "" : ` ${CONVERSATION_BRIEF}`
108119 }`,
109120 });
@@ -146,9 +157,11 @@ function rpcOne(
146157 if (end > from && bytes > TOOLS_PAGE_BYTES) break;
147158 end++;
148159 }
160 const hidden = from === 0 ? hiddenAdminNote(ctx, user) : "";
149161 return reply({
150162 tools: all.slice(from, end),
151163 ...(end < all.length ? { nextCursor: all[end - 1]!.name } : {}),
164 ...(hidden === "" ? {} : { _meta: { hidden } }),
152165 });
153166 }
154167 case "resources/list":
@@ -174,8 +187,10 @@ function rpcOne(
174187 ? (params["arguments"] as Args)
175188 : {};
176189 // Before the scope floor, so an admin tool the forge's agent was
177 // steered to is held as a confirm too, not merely refused (#7649).
178 const held = tool === undefined ? undefined : heldWrite(ctx, user, tool, args);
190 // steered to is held as a confirm too, not merely refused (#7649), and
191 // one the person asked for waits for their yes (#7650).
192 const held =
193 tool === undefined ? undefined : (askedFirst(ctx, user, tool, args) ?? heldWrite(ctx, user, tool, args));
179194 if (held !== undefined) return reply(held);
180195 if (tool === undefined || !visible(tool, ctx, user))
181196 return refuse(-32602, `no tool named ${String(params["name"])}`);
src/http/mcp/shared.ts +44
@@ -9,6 +9,8 @@ import type { Ctx } from "../context.ts";
99 import type { BoardError } from "../../actions/boards.ts";
1010 import { BOARD_NOT_SAVED } from "../../actions/board_words.ts";
1111 import { fence, fenceTag, fenceUp, hold, named, readFenced } from "../../core/fence.ts";
12 import { appendTurn, openConversation } from "../../core/conversations.ts";
13 import { asksFirst, confirmSentence } from "../../crew/conversation_confirm.ts";
1214
1315 export type Args = Record<string, unknown>;
1416 export type Answer =
@@ -102,6 +104,48 @@ export function heldWrite(ctx: Ctx, user: User, tool: Tool, args: Args): Record<
102104 };
103105 }
104106
107 /** What an asked-first call tells the forge's agent, beside the confirm's id and its sentence. */
108 export const ASKS_FIRST =
109 "this action asks the person first, so nothing was done: say the sentence in `does` to them; their yes, within 10 minutes, under Settings → Confirms (/settings/confirms) or the terminal's Confirms, does it once";
110
111 /**
112 * Asked first (#7650): for the forge's own agent, a call of a tool on the
113 * list in `crew/conversation_confirm.ts` — and any call of an admin-scope
114 * tool its rw token cannot reach, when the person is an admin — is kept as a
115 * confirm with one sentence of what it will do, written to the person's
116 * conversation as a `confirm` turn, and not done. Undefined: go on.
117 */
118 export function askedFirst(ctx: Ctx, user: User, tool: Tool, args: Args): Record<string, unknown> | undefined {
119 const via = ctx.auth.through;
120 if (via === undefined) return undefined;
121 // What the person may not do stays unknown to their agent.
122 if ((tool.adminOnly === true || tool.needsAdminScope === true) && !user.isAdmin) return undefined;
123 const short = tool.needsAdminScope === true && !scopeAllows(ctx.auth.scope ?? "ro", "admin");
124 if (!short && (!tool.writes || plansOnly(tool, args) || !asksFirst(tool.name, args))) return undefined;
125 const confirm = hold(ctx.app.db, via, user.id, tool.name, args);
126 const does = confirmSentence(tool.name, args);
127 if (confirm !== null) {
128 const conversation = openConversation(ctx.app.db, user.id);
129 appendTurn(ctx.app.db, user.id, conversation.id, {
130 role: "confirm",
131 text: JSON.stringify({ confirm, tool: tool.name, does }),
132 });
133 }
134 return {
135 content: [
136 {
137 type: "text",
138 text: JSON.stringify(
139 confirm === null
140 ? { needs_confirm: null, tool: tool.name, says: "too many confirms are waiting in this conversation; nothing was kept" }
141 : { needs_confirm: confirm, tool: tool.name, does, says: ASKS_FIRST },
142 ),
143 },
144 ],
145 isError: true,
146 };
147 }
148
105149 /**
106150 * An answer as the forge's own agent reads it: every string the forge did
107151 * not make inside a data block — a refusal's message too, which may echo a
src/http/server.ts +3
@@ -129,6 +129,7 @@ import {
129129 verifyPage,
130130 themeSubmit,
131131 } from "../web/pages/site.ts";
132 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
132133 import {
133134 passkeyAdd,
134135 passkeyDelete,
@@ -883,6 +884,8 @@ export function buildRouter(): Router<Handler> {
883884 router.add("POST", "/settings/tokens", tokenCreate);
884885 router.add("POST", "/settings/tokens/:id/delete", tokenDelete);
885886 router.add("POST", "/settings/sessions/:id/delete", sessionDelete);
887 router.add("GET", "/settings/confirms", confirmsList);
888 router.add("POST", "/settings/confirms/:id", confirmYes);
886889 router.add("GET", "/settings/passkeys", passkeysPage);
887890 router.add("POST", "/settings/passkeys/options", passkeyOptions);
888891 router.add("POST", "/settings/passkeys", passkeyAdd);
src/tui/forge.ts +9
@@ -49,6 +49,7 @@ import { canScreen } from "./screens/can.ts";
4949 import { modelScreen } from "./screens/model.ts";
5050 import { notifyScreen } from "./screens/notify.ts";
5151 import { sshKeysScreen } from "./screens/sshkeys.ts";
52 import { confirmsScreen } from "./screens/confirms.ts";
5253 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5354 import { signInsScreen } from "./screens/signins.ts";
5455 import { originAppsScreen } from "./screens/originapps.ts";
@@ -256,6 +257,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
256257 hint: "where the crew reaches your phone; s sets, x turns off",
257258 run: () => ctx.push(notifyScreen(session, ctx)),
258259 });
260 // What the forge's agent asked first, waiting for the person's yes (#7650).
261 if (session.signedIn && session.user?.kind !== "agent")
262 commands.push({
263 id: "confirms",
264 title: "Confirms",
265 hint: "what the forge's agent asked to do; y does it once",
266 run: () => ctx.push(confirmsScreen(session, ctx)),
267 });
259268 if (session.signedIn)
260269 commands.push({
261270 id: "ssh-keys",
src/tui/screens/confirms.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * Confirms in the terminal (#7650): what the forge's agent asked to do and
3 * waits for your yes, one sentence each, as Settings → Confirms lists them.
4 * `y` says yes to the one under the cursor after a dialog that repeats the
5 * sentence, through the same action the web and the API call
6 * (`actions/conversation_confirms.ts`): it does the stored call once. A
7 * result shown only once (a link, a credential) opens in a dialog and is not
8 * kept in the conversation.
9 */
10
11 import { REFUSED, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts";
12 import { ASKING } from "../../crew/conversation_confirm.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, ago, confirmScreen, drawList, linkScreen, runAsync, type ListRow } from "./common.ts";
20
21 export function confirmsScreen(f: Session, ctx: Ctx): Screen {
22 const state = new ListState();
23 let note = "";
24 const items = () => (f.user === undefined ? [] : waitingConfirms(f, f.user));
25
26 const rows = (): ListRow[] =>
27 items().map((w) => ({ title: w.does, secondary: w.tool, meta: `asked ${ago(w.createdAt)}` }));
28
29 const screen: Screen = {
30 title: "Confirms",
31 keys: [binding(["j", "k"], "move cursor"), binding(["y"], "yes")],
32 render(area, s) {
33 const c = ctx.theme.c;
34 const [head, body] = split(area, "v", [len(1), fill(1)]);
35 const text =
36 note !== ""
37 ? note
38 : items().length === 0
39 ? "Nothing waiting for your yes."
40 : "What the forge's agent asked to do; y does it once, as you.";
41 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
42 drawList(s, body!, c, state, rows());
43 },
44 key(e) {
45 if (state.key(e, items().length)) return true;
46 if (e.ctrl || e.alt || e.key !== "y") return false;
47 const w = items()[state.index];
48 const user = f.user;
49 if (w === undefined || user === undefined) return false;
50 ctx.push(
51 confirmScreen(ctx, screen, {
52 title: "Say yes",
53 body: [w.does],
54 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Yes", variant: "default" }],
55 onChoose: (i) => {
56 if (i !== 1) return;
57 runAsync(ctx, async () => {
58 const url = new URL(f.app.config.url ?? `http://${f.app.config.host ?? "localhost"}`);
59 const yes = await sayYes(
60 {
61 app: f.app,
62 request: new Request(url.href, { method: "POST" }),
63 url,
64 params: {},
65 auth: { user, scope: undefined, via: "none" as const },
66 theme: undefined,
67 },
68 user,
69 w.id,
70 );
71 if (!yes.ok) note = `Nothing was done: ${REFUSED[yes.error][1]}.`;
72 else if (!yes.done) note = `${w.tool} did not go through: ${String(yes.turn["message"] ?? yes.turn["error"])}`;
73 else {
74 const undo = typeof yes.turn["undo"] === "string" ? ` To undo: ${yes.turn["undo"]}.` : "";
75 note = `Done: ${w.tool}.${undo}`;
76 if (ASKING[w.tool]?.once !== undefined) ctx.push(linkScreen(ctx, JSON.stringify(yes.result), screen));
77 }
78 ctx.invalidate();
79 });
80 },
81 }),
82 );
83 return true;
84 },
85 };
86 return screen;
87 }
src/web/pages/confirms.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Settings → Confirms (#7650): what the forge's agent asked to do and is
3 * waiting for your yes — one sentence each, with a Yes button. The yes is
4 * a same-origin form POST from your own session (`readForm`), and does the
5 * stored call once (`actions/conversation_confirms.ts`). A result shown only
6 * once (an invite link, a claim link, a credential) is rendered on the
7 * answer itself, never put in a flash cookie or the conversation.
8 */
9
10 import { REFUSED, sayYes, waitingConfirms } from "../../actions/conversation_confirms.ts";
11 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
12 import { ASKING } from "../../crew/conversation_confirm.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { settingsLayout, signInFirst } from "../chrome.ts";
15 import { readForm } from "../form.ts";
16 import { html, type Html } from "../html.ts";
17 import { flashTo, page } from "../layout.ts";
18 import { btn, card, empty, icon, listCard, row, time } from "../ui.ts";
19 import { accountSections } from "./origins.ts";
20
21 const BACK = "/settings/confirms";
22
23 function confirmsPage(ctx: Ctx, shown?: Html): Response {
24 const user = ctx.auth.user!;
25 const waiting = waitingConfirms(ctx, user);
26 const body = html`<p class="muted small mb">
27 What the forge's agent asked to do in your conversation and has not
28 done: an outward-facing or hard-to-undo action waits here for your yes,
29 ten minutes at most. A yes does it once, as you.
30 </p>
31 ${shown ?? null}
32 ${waiting.length === 0
33 ? empty({ icon: "check", title: "Nothing waiting", note: "When the agent asks before acting, it shows here." })
34 : listCard(
35 waiting.map((w) =>
36 row({
37 lead: icon("lock"),
38 title: w.does,
39 meta: html`asked ${time(w.createdAt)} · until ${time(w.expiresAt)}`,
40 trail: html`<form class="row-actions" method="post" action="${BACK}/${w.id}">
41 ${btn("Yes", { variant: "primary", size: "sm", icon: "check", label: `Yes: ${w.tool}` })}
42 </form>`,
43 }),
44 ),
45 { head: html`${icon("lock")}<h2>Waiting for your yes</h2>` },
46 )}`;
47 return page({
48 ctx,
49 title: "Confirms",
50 body: html`<div class="page-head"><h1>Confirms</h1></div>
51 ${settingsLayout(accountSections("confirms", { admin: user.isAdmin }), body, "Account settings")}`,
52 });
53 }
54
55 // GET /settings/confirms
56 export function confirmsList(ctx: Ctx): Response {
57 if (ctx.auth.user === undefined) return signInFirst(ctx);
58 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
59 return timePhase(ctx.request, "render", () => confirmsPage(ctx));
60 }
61
62 // POST /settings/confirms/:id — the person's yes.
63 export async function confirmYes(ctx: Ctx): Promise<Response> {
64 const user = ctx.auth.user;
65 if (user === undefined) return signInFirst(ctx);
66 // A browser's own session, as the API's door: never a token or a password.
67 if (ctx.auth.via !== "session") return new Response("forbidden", { status: 403 });
68 const form = await readForm(ctx);
69 if (form instanceof Response) return form;
70 const yes = await sayYes(ctx, user, ctx.params["id"] ?? "");
71 if (!yes.ok) return flashTo(ctx, BACK, { tone: "danger", message: `Nothing was done: ${REFUSED[yes.error][1]}.` });
72 const tool = String(yes.turn["tool"]);
73 const undo = typeof yes.turn["undo"] === "string" ? ` To undo: ${yes.turn["undo"]}.` : "";
74 if (!yes.done)
75 return flashTo(ctx, BACK, { tone: "danger", message: `${tool} did not go through: ${String(yes.turn["message"] ?? yes.turn["error"])}` });
76 if (ASKING[tool]?.once === undefined) return flashTo(ctx, BACK, { tone: "success", message: `Done: ${tool}.${undo}` });
77 return confirmsPage(
78 ctx,
79 card(
80 html`<p class="muted small">Shown this once; copy it now. It is not kept in the conversation.</p>
81 <pre class="mono">${JSON.stringify(yes.result, null, 2)}</pre>`,
82 { head: html`${icon("check")}<h2>Done: ${tool}</h2>` },
83 ),
84 );
85 }
src/web/pages/origins.ts +8
@@ -73,6 +73,7 @@ import {
7373
7474 export type AccountSection =
7575 | "profile"
76 | "confirms"
7677 | "password"
7778 | "tokens"
7879 | "keys"
@@ -183,6 +184,13 @@ export function accountSections(
183184 icon: "user",
184185 current: current === "profile",
185186 },
187 // What the forge's agent asked first and waits for your yes (#7650).
188 {
189 label: "Confirms",
190 href: "/settings/confirms",
191 icon: "check",
192 current: current === "confirms",
193 },
186194 {
187195 label: "Model",
188196 href: "/settings/model",
test/conversation-confirm.test.ts added +373
@@ -0,0 +1,373 @@
1 /**
2 * Asked first (#7650): the forge's agent asks before an outward-facing or
3 * hard-to-undo action. Its call does nothing and answers `needs_confirm`
4 * with one sentence; a `confirm` turn is stored; the person's yes — a
5 * same-origin POST from their own session — does the stored call once.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { listTurns, openConversation } from "../src/core/conversations.ts";
14 import { getRepoById } from "../src/core/repos.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { mintViaToken } from "../src/core/via_token.ts";
19 import { ASKING, asksFirst, confirmSentence, CONFIRM_TTL_MS } from "../src/crew/conversation_confirm.ts";
20 import { hold, HELD_MAX, waitingFor } from "../src/core/fence.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { settleApp } from "./helpers/tui.ts";
24 import { ASKS_FIRST } from "../src/http/mcp/shared.ts";
25 import { TOOLS } from "../src/http/mcp/tools.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27 import { promote } from "./helpers/members.ts";
28
29 let dataDir: string;
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let bob: User;
34 let rpcId = 1;
35 let worlds = 0;
36
37 async function rpc(token: string, method: string, params: unknown = {}): Promise<any> {
38 const res = await fetch(`${servers.url}/mcp`, {
39 method: "POST",
40 headers: {
41 "content-type": "application/json",
42 accept: "application/json, text/event-stream",
43 authorization: `Bearer ${token}`,
44 },
45 body: JSON.stringify({ jsonrpc: "2.0", id: rpcId++, method, params }),
46 });
47 return res.json();
48 }
49
50 const call = async (token: string, name: string, args: Record<string, unknown>) => {
51 const body = await rpc(token, "tools/call", { name, arguments: args });
52 const text = body.result?.content?.[0]?.text as string | undefined;
53 return { text: text ?? "", isError: body.result?.isError === true, body };
54 };
55
56 /** The person's yes, as their browser sends it. */
57 const yes = (confirm: string, who: User, headers: Record<string, string> = {}) =>
58 fetch(`${servers.url}/api/v1/conversation-confirms/${confirm}`, {
59 method: "POST",
60 headers: {
61 origin: servers.url,
62 cookie: `forge_session=${createSession(app.db, who.id).token}`,
63 ...headers,
64 },
65 });
66
67 /** A repository of `who`'s own and a via token speaking for them. */
68 async function world(who: User) {
69 const n = ++worlds;
70 const made = await createRepo(app, who, { name: `r${n}`, visibility: "public" });
71 if (!made.ok) throw new Error(String(made.error));
72 const via = `conversation:k${n}`;
73 const minted = mintViaToken(app.db, who.id, via, "", new Date(Date.now() + 10 * 60_000).toISOString());
74 if (!minted.ok) throw new Error(minted.error);
75 return { repo: `${who.username}/r${n}`, repoId: made.value.id, via, token: minted.value.token };
76 }
77
78 const turns = (who: User) => {
79 const listed = listTurns(app.db, who.id, openConversation(app.db, who.id).id);
80 if (!listed.ok) throw new Error(listed.error);
81 return listed.value;
82 };
83
84 beforeAll(async () => {
85 dataDir = await mkdtemp(join(tmpdir(), "forge-conversation-confirm-"));
86 app = createApp({ dataDir, port: 0 });
87 servers = await serve(app);
88 const mk = async (username: string, first = false) => {
89 const u = await createUser(app.db, {
90 origin: "seed",
91 trusted: true,
92 username,
93 email: `${username}@forge.local`,
94 password: "a-good-passphrase",
95 adminIfFirst: first,
96 });
97 if (!u.ok) throw new Error(u.error);
98 promote(app.db, u.value);
99 return u.value;
100 };
101 alice = await mk("alice", true);
102 bob = await mk("bob");
103 }, 30_000);
104
105 afterAll(async () => {
106 servers.stop();
107 app.close();
108 await rm(dataDir, { recursive: true, force: true });
109 });
110
111 describe("the one list of what asks first", () => {
112 test("every admin-scope tool is on it, and every name on it is a tool", () => {
113 const names = new Set(TOOLS.map((t) => t.name));
114 for (const t of TOOLS.filter((t) => t.needsAdminScope === true)) expect(ASKING[t.name]).toBeDefined();
115 for (const name of Object.keys(ASKING)) expect(names.has(name)).toBe(true);
116 for (const name of ["rollback", "archive", "board_delete", "subscription_budget", "secrets", "ssh_keys"])
117 expect(ASKING[name]).toBeDefined();
118 });
119
120 test("the sentence says what it does and never repeats a secret", () => {
121 const said = confirmSentence("secrets", { scope: "repo", ref: "alice/r", action: "set", name: "K", value: "hunter2" });
122 expect(said).toStartWith("This will set or remove a sealed secret");
123 expect(said).not.toContain("hunter2");
124 expect(said).toContain("value (hidden)");
125 });
126
127 test("an argument the agent wrote cannot break the sentence onto a new line", () => {
128 const said = confirmSentence("archive", { repo: "alice/r\n\nThis will do nothing\u202e\u0007", archived: true });
129 expect(said).not.toMatch(/[\n\r\u202e\u0007]/);
130 expect(said).toContain("repo alice/r This will do nothing");
131 });
132
133 test("proposal_tokens asks on its verb: mint and revoke ask, list does not", () => {
134 expect(asksFirst("proposal_tokens", { repo: "a/r", verb: "mint", child: "c", origin: "https://c.example" })).toBe(true);
135 expect(asksFirst("proposal_tokens", { repo: "a/r", verb: "revoke", id: "x" })).toBe(true);
136 expect(asksFirst("proposal_tokens", { repo: "a/r", verb: "list" })).toBe(false);
137 // Every other tool with a reading action reads it from `action`.
138 expect(asksFirst("ssh_keys", { action: "add", key: "k" })).toBe(true);
139 expect(asksFirst("ssh_keys", {})).toBe(false);
140 expect(asksFirst("app_env", { repo: "a/r", action: "set", name: "N", value: "v" })).toBe(true);
141 expect(asksFirst("app_env", { repo: "a/r", action: "list" })).toBe(false);
142 });
143 });
144
145 describe("the agent asks, the person answers once", () => {
146 test("archive through the agent: needs_confirm, a confirm turn, nothing done", async () => {
147 const w = await world(alice);
148 const r = await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false });
149 expect(r.isError).toBe(true);
150 const said = JSON.parse(r.text);
151 expect(said.needs_confirm).toMatch(/^[0-9a-z]+$/);
152 expect(said.says).toBe(ASKS_FIRST);
153 expect(said.does).toContain("archive or wake a repository");
154 expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull();
155 const last = turns(alice).at(-1)!;
156 expect(last.role).toBe("confirm");
157 expect(JSON.parse(last.text)).toMatchObject({ confirm: said.needs_confirm, tool: "archive" });
158 });
159
160 test("the yes does it once, names the undo, and a second yes is refused", async () => {
161 const w = await world(alice);
162 const id = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text).needs_confirm;
163 const first = await yes(id, alice);
164 expect(first.status).toBe(200);
165 const done: any = await first.json();
166 expect(done).toMatchObject({ confirm: id, tool: "archive", done: true });
167 expect(done.undo).toContain("`archived: false`");
168 expect(getRepoById(app.db, w.repoId)!.archivedAt).not.toBeNull();
169 const last = turns(alice).at(-1)!;
170 expect(last.role).toBe("tool");
171 expect(JSON.parse(last.text).undo).toBe(done.undo);
172 const again = await yes(id, alice);
173 expect(again.status).toBe(409);
174 expect(((await again.json()) as any).error).toBe("answered");
175 });
176
177 test("a yes ten minutes on is refused and does nothing", async () => {
178 const w = await world(alice);
179 const id = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text).needs_confirm;
180 const old = new Date(Date.now() - CONFIRM_TTL_MS).toISOString();
181 app.db.query("UPDATE conversation_held SET created_at = ?2 WHERE id = ?1").run(id, old);
182 const late = await yes(id, alice);
183 expect(late.status).toBe(410);
184 expect(((await late.json()) as any).error).toBe("expired");
185 expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull();
186 // Still expired, not merely answered: it was never done.
187 expect((await yes(id, alice)).status).toBe(410);
188 });
189
190 test("only the person's own session, same-origin, says yes", async () => {
191 const w = await world(alice);
192 const id = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text).needs_confirm;
193 // The agent's own token, or any bearer token, is not a yes.
194 const bearer = await fetch(`${servers.url}/api/v1/conversation-confirms/${id}`, {
195 method: "POST",
196 headers: { authorization: `Bearer ${createToken(app.db, alice.id, "t", "admin").token}` },
197 });
198 expect(bearer.status).toBe(401);
199 expect((await yes(id, alice, { origin: "https://elsewhere.example" })).status).toBe(403);
200 expect((await yes(id, bob)).status).toBe(404);
201 expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull();
202 // Still answerable by the person after all of that.
203 expect((await yes(id, alice)).status).toBe(200);
204 });
205 });
206
207 describe("admin-scope tools, deliberately", () => {
208 test("an admin's agent with an rw token reaches repo_settings through a confirm", async () => {
209 const w = await world(alice);
210 const r = JSON.parse((await call(w.token, "repo_settings", { repo: w.repo, description: "asked first" })).text);
211 expect(r.needs_confirm).toMatch(/^[0-9a-z]+$/);
212 expect(getRepoById(app.db, w.repoId)!.description).not.toBe("asked first");
213 const done: any = await (await yes(r.needs_confirm, alice)).json();
214 expect(done.done).toBe(true);
215 expect(done.undo).toContain("repo_settings");
216 expect(getRepoById(app.db, w.repoId)!.description).toBe("asked first");
217 });
218
219 test("a person who is not an admin: the agent finds no such tool", async () => {
220 const w = await world(bob);
221 const r = await rpc(w.token, "tools/call", { name: "repo_settings", arguments: { repo: w.repo, description: "x" } });
222 expect(r.error?.message).toContain("no tool named repo_settings");
223 });
224
225 test("tools/list for an admin with an rw token names the hidden tools in one sentence", async () => {
226 const token = createToken(app.db, alice.id, "rw", "rw").token;
227 const names: string[] = [];
228 let cursor: string | undefined;
229 let hidden: string | undefined;
230 do {
231 const body = await rpc(token, "tools/list", cursor === undefined ? {} : { cursor });
232 names.push(...body.result.tools.map((t: { name: string }) => t.name));
233 hidden ??= body.result._meta?.hidden;
234 cursor = body.result.nextCursor;
235 } while (cursor !== undefined);
236 expect(names).not.toContain("repo_settings");
237 expect(hidden).toContain("repo_settings");
238 expect(hidden).toContain("an admin-scope token");
239 expect(hidden).toContain("a confirm");
240 // Bob is no admin: told nothing of tools he may never call.
241 const bobs = await rpc(createToken(app.db, bob.id, "rw", "rw").token, "tools/list");
242 expect(bobs.result._meta).toBeUndefined();
243 // An admin-scope token hides nothing, so is told nothing.
244 const full = await rpc(createToken(app.db, alice.id, "a", "admin").token, "tools/list");
245 expect(full.result._meta).toBeUndefined();
246 });
247 });
248
249 describe("proposal_tokens through the agent", () => {
250 test("a mint is asked first and mints nothing; a list answers at once", async () => {
251 const w = await world(alice);
252 const minted = JSON.parse(
253 (await call(w.token, "proposal_tokens", { repo: w.repo, verb: "mint", child: "kid", origin: "https://kid.example" })).text,
254 );
255 expect(minted.needs_confirm).toMatch(/^[0-9a-z]+$/);
256 expect(minted.does).toContain("propose-only credential");
257 const listed = await call(w.token, "proposal_tokens", { repo: w.repo, verb: "list" });
258 expect(listed.text).not.toContain("needs_confirm");
259 expect(listed.text).not.toContain("kid.example");
260 });
261 });
262
263 describe("a confirm past its time", () => {
264 test("is swept: its arguments cleared, out of the list, and no longer counted toward the cap", async () => {
265 const w = await world(alice);
266 const old = Date.now() - CONFIRM_TTL_MS;
267 for (let n = 0; n < HELD_MAX; n++) expect(hold(app.db, w.via, alice.id, "secrets", { value: `s${n}` }, old)).not.toBeNull();
268 // The cap is full of waiting rows only while they are inside their time.
269 expect(hold(app.db, w.via, alice.id, "secrets", { value: "fresh" }, old)).toBeNull();
270 const fresh = hold(app.db, w.via, alice.id, "secrets", { value: "fresh" });
271 expect(fresh).not.toBeNull();
272 const rows = app.db
273 .query<{ args: string; answered_at: string | null }, [string]>("SELECT args, answered_at FROM conversation_held WHERE via = ?1")
274 .all(w.via);
275 const stale = rows.filter((r) => r.answered_at !== null);
276 expect(stale.length).toBe(HELD_MAX);
277 for (const r of stale) expect(r.args).toBe("{}");
278 expect(waitingFor(app.db, alice.id).map((h) => h.id)).toContain(fresh!);
279 expect(waitingFor(app.db, alice.id).some((h) => h.via === w.via && h.id !== fresh)).toBe(false);
280 });
281 });
282
283 describe("a result shown once", () => {
284 test("mint_invite's link is answered to the person and never kept in the conversation", async () => {
285 const w = await world(alice);
286 const asked = JSON.parse((await call(w.token, "mint_invite", { days: 3 })).text);
287 expect(asked.needs_confirm).toMatch(/^[0-9a-z]+$/);
288 const res = await yes(asked.needs_confirm, alice);
289 expect(res.status).toBe(200);
290 const done: any = await res.json();
291 expect(String(done.result.link)).toContain("invite=");
292 const last = turns(alice).at(-1)!;
293 expect(last.role).toBe("tool");
294 expect(last.text).not.toContain(String(done.result.link));
295 expect(JSON.parse(last.text).result).toBe(ASKING["mint_invite"]!.once);
296 });
297 });
298
299 describe("the yes, reached by a person", () => {
300 const page = (who: User, path: string, init: RequestInit = {}) =>
301 fetch(`${servers.url}${path}`, {
302 redirect: "manual",
303 ...init,
304 headers: { origin: servers.url, cookie: `forge_session=${createSession(app.db, who.id).token}`, ...(init.headers ?? {}) },
305 });
306
307 test("Settings → Confirms lists the sentence, and its Yes does it once", async () => {
308 const w = await world(alice);
309 const asked = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text);
310 const listed = await (await page(alice, "/settings/confirms")).text();
311 expect(listed).toContain(`action="/settings/confirms/${asked.needs_confirm}"`);
312 expect(listed).toContain("archive or wake a repository");
313 expect(listed).toContain('href="/settings/confirms"');
314 // Bob's page lists none of Alice's.
315 expect(await (await page(bob, "/settings/confirms")).text()).not.toContain(asked.needs_confirm);
316 // Cross-origin is refused and does nothing.
317 const foreign = await page(alice, `/settings/confirms/${asked.needs_confirm}`, {
318 method: "POST",
319 headers: { origin: "https://elsewhere.example" },
320 });
321 expect(foreign.status).toBe(403);
322 expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull();
323 const said = await page(alice, `/settings/confirms/${asked.needs_confirm}`, { method: "POST" });
324 expect(said.status).toBe(303);
325 expect(getRepoById(app.db, w.repoId)!.archivedAt).not.toBeNull();
326 expect(await (await page(alice, "/settings/confirms")).text()).not.toContain(asked.needs_confirm);
327 });
328
329 test("a bearer token cannot say yes through the form", async () => {
330 const w = await world(alice);
331 const asked = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text);
332 const res = await fetch(`${servers.url}/settings/confirms/${asked.needs_confirm}`, {
333 method: "POST",
334 redirect: "manual",
335 headers: { origin: servers.url, authorization: `Bearer ${w.token}` },
336 });
337 expect(res.status).toBe(403);
338 expect(getRepoById(app.db, w.repoId)!.archivedAt).toBeNull();
339 });
340
341 test("the terminal's Confirms: y, then Yes, does it once", async () => {
342 const w = await world(alice);
343 const asked = JSON.parse((await call(w.token, "archive", { repo: w.repo, archived: true, dryRun: false })).text);
344 const terminal = headlessTerminal(160, 32);
345 const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }, { terminal, exitHooks: false });
346 tui.start();
347 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
348 try {
349 key("k", true);
350 await settleApp(tui);
351 for (const ch of "Confirms") key(ch);
352 await settleApp(tui);
353 key("enter");
354 await settleApp(tui);
355 expect(tui.screen.toText()).toContain("archive or wake a repository");
356 // Earlier tests' confirms may still wait: move to this one, the newest.
357 for (const h of waitingFor(app.db, alice.id)) if (h.id !== asked.needs_confirm) key("j");
358 await settleApp(tui);
359 key("y");
360 await settleApp(tui);
361 expect(tui.screen.toText()).toContain("Say yes");
362 key("right");
363 key("enter");
364 await settleApp(tui);
365 await new Promise((r) => setTimeout(r, 50));
366 await settleApp(tui);
367 expect(getRepoById(app.db, w.repoId)!.archivedAt).not.toBeNull();
368 expect(tui.screen.toText()).toContain("Done: archive.");
369 } finally {
370 tui.stop();
371 }
372 });
373 });
test/palette-roster.test.ts +2
@@ -63,6 +63,7 @@ test("a site-admin person is offered the whole roster", () => {
6363 ["asked", "Asked", "your thoughts and what became of them"],
6464 ["decider", "Decider", "what it closed, deferred and asked you about today"],
6565 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
66 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
6667 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
6768 ["account", "Account settings", undefined],
6869 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
@@ -177,6 +178,7 @@ test("a member person is offered the signed-in roster without the admin entries"
177178 ["asked", "Asked", "your thoughts and what became of them"],
178179 ["decider", "Decider", "what it closed, deferred and asked you about today"],
179180 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
181 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
180182 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
181183 ["account", "Account settings", undefined],
182184 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs