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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7650-outward-facing-and-hard-to-undo-actions → main 8 commits fork 2 threads · 2 unresolved
21 files
changed +1080
−35
docs/MCP.md +51 −19
| @@ -35,10 +35,10 @@ for a tunnel and a forge that sleeps. | ||
| 35 | 35 | | `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 | |
| 36 | 36 | | `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 | |
| 37 | 37 | | `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 | | |
| 39 | 39 | | `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 | | |
| 40 | 40 | | `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 | | |
| 42 | 42 | | `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 | |
| 43 | 43 | | `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 | |
| 44 | 44 | | `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. | ||
| 54 | 54 | | `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` | | |
| 55 | 55 | | `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 | |
| 56 | 56 | | `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 | | |
| 59 | 59 | | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes | |
| 60 | 60 | | `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 | |
| 61 | 61 | | `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. | ||
| 96 | 96 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 97 | 97 | | `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 | | |
| 98 | 98 | | `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 | | |
| 100 | 100 | | `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 | |
| 101 | 101 | | `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 | | |
| 102 | 102 | | `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. | ||
| 111 | 111 | | `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 | |
| 112 | 112 | | `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) | | |
| 113 | 113 | | `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 | | |
| 116 | 116 | | `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 | |
| 117 | 117 | | `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 | |
| 118 | 118 | | `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. | ||
| 120 | 120 | | `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 | |
| 121 | 121 | | `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`) | | |
| 122 | 122 | | `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 | | |
| 124 | 124 | | `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` | | |
| 125 | 125 | | `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 | |
| 126 | 126 | | `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. | ||
| 132 | 132 | | `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 | | |
| 133 | 133 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 134 | 134 | | `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 | | |
| 136 | 136 | | `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 | |
| 137 | 137 | | `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 | |
| 138 | 138 | | `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. | ||
| 140 | 140 | | `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` | | |
| 141 | 141 | | `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 | |
| 142 | 142 | | `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 | | |
| 144 | 144 | | `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 | | |
| 145 | 145 | | `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 | | |
| 146 | 146 | | `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` | | |
| 147 | 147 | | `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 | | |
| 149 | 149 | | `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 | |
| 150 | 150 | | `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 | |
| 151 | 151 | | `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 | |
| 152 | 152 | | `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 | | |
| 153 | 153 | | `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 | | |
| 155 | 155 | | `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 | | |
| 156 | 156 | | `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 | | |
| 157 | 157 | | `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. | ||
| 159 | 159 | | `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 | |
| 160 | 160 | | `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 | | |
| 161 | 161 | | `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 | | |
| 163 | 163 | | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes | |
| 164 | 164 | | `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 | |
| 165 | 165 | | `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. | ||
| 170 | 170 | | `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 | |
| 171 | 171 | | `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 | |
| 172 | 172 | | `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 | | |
| 178 | 178 | | `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 | | |
| 179 | 179 | | `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 | |
| 180 | 180 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
| 181 | 181 | | `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 | |
| 182 | 182 | | `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 | |
| 183 | 183 | | `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 | | |
| 185 | 185 | | `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 | | |
| 186 | 186 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 187 | 187 | | `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. | ||
| 234 | 234 | Answers are JSON in a text block; most carry a `says` sentence short enough |
| 235 | 235 | to relay as it is. |
| 236 | 236 | |
| 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 | ||
| 237 | 269 | ## Arguments, in full |
| 238 | 270 | |
| 239 | 271 | `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`, | ||
| 118 | 118 | - `/settings/tokens` — `pages/account.ts` · my API tokens · `/settings/keys` (credentials of two kinds on two pages) · colour 1. |
| 119 | 119 | - `/settings/keys` — `pages/account.ts` · my SSH keys · `/settings/tokens` · colour 1. |
| 120 | 120 | - `/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. | |
| 121 | 122 | - `/settings/secrets` — `pages/secrets.ts` · secrets apps may read · none · none seen. |
| 122 | 123 | - `/settings/subscriptions` — `pages/subscriptions.ts` (624 lines) · the model subscriptions the crew spends from, with spend tables (`pages/spend.ts`) · `/board/returns` · none seen. |
| 123 | 124 | - `/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 { | ||
| 40 | 40 | ].join("\n"); |
| 41 | 41 | } |
| 42 | 42 | |
| 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 | }; | |
| 44 | 49 | |
| 45 | 50 | export function tableText(tools: readonly Documented[]): string { |
| 46 | 51 | const rows = [...tools] |
| 47 | 52 | .filter((t) => t.doc !== undefined) |
| 48 | 53 | .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" : ""} |`); | |
| 50 | 56 | return [BEGIN, "", "| tool | does | writes |", "| ---- | ---- | ------ |", ...rows, "", END].join("\n"); |
| 51 | 57 | } |
| 52 | 58 |
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"; | ||
| 187 | 187 | export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts"; |
| 188 | 188 | export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts"; |
| 189 | 189 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 190 | export { route as postApiV1ConversationConfirmsId } from "./postApiV1ConversationConfirmsId.ts"; | |
| 190 | 191 | export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts"; |
| 191 | 192 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 192 | 193 | 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 { | ||
| 202 | 202 | /** A conversation keeps at most this many held writes; past it, a write is refused unkept. */ |
| 203 | 203 | export const HELD_MAX = 50; |
| 204 | 204 | |
| 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 | ||
| 205 | 220 | /** |
| 206 | 221 | * 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 | |
| 223 | * fill the table). Those past their time are swept first, so they never | |
| 224 | * count; counted and kept in one statement. | |
| 209 | 225 | */ |
| 210 | 226 | export function hold( |
| 211 | 227 | db: Database, |
| @@ -213,16 +229,20 @@ export function hold( | ||
| 213 | 229 | userId: string, |
| 214 | 230 | tool: string, |
| 215 | 231 | args: Record<string, unknown>, |
| 232 | now: number = Date.now(), | |
| 216 | 233 | ): string | null { |
| 217 | 234 | 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 | })(); | |
| 226 | 246 | } |
| 227 | 247 | |
| 228 | 248 | /** A conversation's held writes, oldest first. */ |
| @@ -242,3 +262,73 @@ export function heldIn(db: Database, via: string): Held[] { | ||
| 242 | 262 | createdAt: r.created_at, |
| 243 | 263 | })); |
| 244 | 264 | } |
| 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
|
||
| 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"; | ||
| 324 | 324 | import { reportRoutes } from "./api/reports.ts"; |
| 325 | 325 | import { rollbackRoutes } from "./api/rollback.ts"; |
| 326 | 326 | import { takeBackRoutes } from "./api/take_back.ts"; |
| 327 | import { conversationConfirmRoutes } from "./api/conversation_confirms.ts"; | |
| 327 | 328 | import { orgRoutes } from "./api/orgs.ts"; |
| 328 | 329 | import { promptRoutes } from "./api/prompts.ts"; |
| 329 | 330 | import { secretRoutes } from "./api/secrets.ts"; |
| @@ -1871,6 +1872,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1871 | 1872 | appNoteRoutes(router); |
| 1872 | 1873 | rollbackRoutes(router); |
| 1873 | 1874 | takeBackRoutes(router); |
| 1875 | conversationConfirmRoutes(router); | |
| 1874 | 1876 | |
| 1875 | 1877 | // Which stage of the one flow each open issue is in, and what went live in |
| 1876 | 1878 | // 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"; | ||
| 32 | 32 | import { REPORT_RECEIVED } from "../actions/report_words.ts"; |
| 33 | 33 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 34 | 34 | 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"; | |
| 36 | 36 | import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts"; |
| 37 | 37 | import { TOOLS } from "./mcp/tools.ts"; |
| 38 | 38 | |
| @@ -59,6 +59,16 @@ function adminNote(ctx: Ctx, user: User): string { | ||
| 59 | 59 | }`; |
| 60 | 60 | } |
| 61 | 61 | |
| 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 | ||
| 62 | 72 | /** A card's model beside the text, for a host that draws `ui://` resources. */ |
| 63 | 73 | function cardResult( |
| 64 | 74 | name: string, |
| @@ -97,13 +107,14 @@ function rpcOne( | ||
| 97 | 107 | switch (m["method"]) { |
| 98 | 108 | case "initialize": { |
| 99 | 109 | const asked = str(params["protocolVersion"]); |
| 110 | const hidden = hiddenAdminNote(ctx, user); | |
| 100 | 111 | return reply({ |
| 101 | 112 | protocolVersion: (PROTOCOLS as readonly string[]).includes(asked) |
| 102 | 113 | ? asked |
| 103 | 114 | : PROTOCOLS[0], |
| 104 | 115 | capabilities: { tools: {}, resources: {} }, |
| 105 | 116 | 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}`}${ | |
| 107 | 118 | ctx.auth.through === undefined ? "" : ` ${CONVERSATION_BRIEF}` |
| 108 | 119 | }`, |
| 109 | 120 | }); |
| @@ -146,9 +157,11 @@ function rpcOne( | ||
| 146 | 157 | if (end > from && bytes > TOOLS_PAGE_BYTES) break; |
| 147 | 158 | end++; |
| 148 | 159 | } |
| 160 | const hidden = from === 0 ? hiddenAdminNote(ctx, user) : ""; | |
| 149 | 161 | return reply({ |
| 150 | 162 | tools: all.slice(from, end), |
| 151 | 163 | ...(end < all.length ? { nextCursor: all[end - 1]!.name } : {}), |
| 164 | ...(hidden === "" ? {} : { _meta: { hidden } }), | |
| 152 | 165 | }); |
| 153 | 166 | } |
| 154 | 167 | case "resources/list": |
| @@ -174,8 +187,10 @@ function rpcOne( | ||
| 174 | 187 | ? (params["arguments"] as Args) |
| 175 | 188 | : {}; |
| 176 | 189 | // 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)); | |
| 179 | 194 | if (held !== undefined) return reply(held); |
| 180 | 195 | if (tool === undefined || !visible(tool, ctx, user)) |
| 181 | 196 | return refuse(-32602, `no tool named ${String(params["name"])}`); |
src/http/server.ts +3
| @@ -129,6 +129,7 @@ import { | ||
| 129 | 129 | verifyPage, |
| 130 | 130 | themeSubmit, |
| 131 | 131 | } from "../web/pages/site.ts"; |
| 132 | import { confirmsList, confirmYes } from "../web/pages/confirms.ts"; | |
| 132 | 133 | import { |
| 133 | 134 | passkeyAdd, |
| 134 | 135 | passkeyDelete, |
| @@ -883,6 +884,8 @@ export function buildRouter(): Router<Handler> { | ||
| 883 | 884 | router.add("POST", "/settings/tokens", tokenCreate); |
| 884 | 885 | router.add("POST", "/settings/tokens/:id/delete", tokenDelete); |
| 885 | 886 | router.add("POST", "/settings/sessions/:id/delete", sessionDelete); |
| 887 | router.add("GET", "/settings/confirms", confirmsList); | |
| 888 | router.add("POST", "/settings/confirms/:id", confirmYes); | |
| 886 | 889 | router.add("GET", "/settings/passkeys", passkeysPage); |
| 887 | 890 | router.add("POST", "/settings/passkeys/options", passkeyOptions); |
| 888 | 891 | router.add("POST", "/settings/passkeys", passkeyAdd); |
src/tui/forge.ts +9
| @@ -49,6 +49,7 @@ import { canScreen } from "./screens/can.ts"; | ||
| 49 | 49 | import { modelScreen } from "./screens/model.ts"; |
| 50 | 50 | import { notifyScreen } from "./screens/notify.ts"; |
| 51 | 51 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 52 | import { confirmsScreen } from "./screens/confirms.ts"; | |
| 52 | 53 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 53 | 54 | import { signInsScreen } from "./screens/signins.ts"; |
| 54 | 55 | import { originAppsScreen } from "./screens/originapps.ts"; |
| @@ -256,6 +257,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 256 | 257 | hint: "where the crew reaches your phone; s sets, x turns off", |
| 257 | 258 | run: () => ctx.push(notifyScreen(session, ctx)), |
| 258 | 259 | }); |
| 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 | }); | |
| 259 | 268 | if (session.signedIn) |
| 260 | 269 | commands.push({ |
| 261 | 270 | 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 { | ||
| 73 | 73 | |
| 74 | 74 | export type AccountSection = |
| 75 | 75 | | "profile" |
| 76 | | "confirms" | |
| 76 | 77 | | "password" |
| 77 | 78 | | "tokens" |
| 78 | 79 | | "keys" |
| @@ -183,6 +184,13 @@ export function accountSections( | ||
| 183 | 184 | icon: "user", |
| 184 | 185 | current: current === "profile", |
| 185 | 186 | }, |
| 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 | }, | |
| 186 | 194 | { |
| 187 | 195 | label: "Model", |
| 188 | 196 | 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", () => { | ||
| 63 | 63 | ["asked", "Asked", "your thoughts and what became of them"], |
| 64 | 64 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 65 | 65 | ["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"], | |
| 66 | 67 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 67 | 68 | ["account", "Account settings", undefined], |
| 68 | 69 | ["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" | ||
| 177 | 178 | ["asked", "Asked", "your thoughts and what became of them"], |
| 178 | 179 | ["decider", "Decider", "what it closed, deferred and asked you about today"], |
| 179 | 180 | ["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"], | |
| 180 | 182 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 181 | 183 | ["account", "Account settings", undefined], |
| 182 | 184 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
new above
offline
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.