Skip to content

[bug] #403 breaks the Terminal tab: the console calls terminal_* by name, and the tmux Operator will no longer declare them #409

Closed

Description

@serge-ivo

Found while reviewing the #403 delivery (branch worktree-agent-a667a7d7b9f5038f3, commit c474a70, migration 0099_tmux_operator_backend_exclusive_tools.sql — committed, not yet pushed). The migration names this cost in its own header. This ticket is the fix, the measurement, and the generalisation.

The problem, from the owner's position

You open tmux Operator → Terminal. The runner is up. Instead of a pane you get a red line:

"terminal_list_targets" is not one of this agent's tools. It can only run the tools its agent declares.

…which is addressed to a creator, not to you — you declared nothing. Underneath it, the pane's empty state says:

No terminal targets found on <machine>. Open tmux, kitty, or iTerm2 there, or create one from Controls.

That sentence is false and actively misleading: it sends you to open a tmux session, which will not help, because nothing was ever asked of the machine. Every write control is disabled, and the pair of failing calls repeats every 4 seconds, forever — tmuxBusy(targets, !!error || runner?.online === false) returns busy because the last call failed, pinning the fast tier (TmuxTab.tsx:202, rule in lib/pollBusy.ts:109-113). Chat-driven tmux is unaffected and becomes more correct.

Verified — yes, this is real

Measured, not inferred. Executing the real gate functions against 0099's declared list:

BEFORE (terminal_* declared): terminal_list_targets=true  … terminal_kill_target=true
AFTER  (tmux_*     declared): terminal_list_targets=false … terminal_kill_target=false
AFTER  tmux_* present       : tmux_list_sessions=true     … tmux_kill_session=true

POLICY terminal_list_targets: allowed=false reason=not_declared
POLICY terminal_capture:      allowed=false reason=not_declared
POLICY terminal_run_command:  allowed=false reason=not_declared
POLICY terminal_send_keys:    allowed=false reason=not_declared
POLICY terminal_new_target:   allowed=false reason=not_declared
POLICY terminal_kill_target:  allowed=false reason=not_declared

Reproduced end-to-end against production, on the real route with the real gate. tmux_* is not_declared on that instance today, so calling it now is an exact simulation of what terminal_* becomes after 0099:

POST https://api.proagentstore.online/v1/instances/cda75e28-…/tools/tmux_list_sessions
→ HTTP 403
  {"error":"\"tmux_list_sessions\" is not one of this agent's tools. It can only run the tools its agent declares."}

Live "before" state (GET /v1/instances/:id/tools, both tmux-operator instances):

tool today after 0099
terminal_* ×6 allowed=true reason=ok allowed=false reason=not_declared
tmux_* ×6 allowed=false reason=not_declared allowed=true

The mechanism — two correct decisions composing

1. The tab gates its writes on the policy but treats its reads as unconditional. 1f3ca00 "fix(console): gate terminal actions per tool" deliberately introduced per-tool gating — TmuxTab.tsx:205-208 reads the live verdict for each of the four write tools. It did not gate the two reads, because at the time the reads were guaranteed present: every agent on this surface declared terminal_*. So refreshTargets (:149) and capture (:169) fire unconditionally.

2. 0099 correctly narrows the allowlist and correctly keeps the surface. toolNamesFor (agent-do-tools.ts:187-193) is an exclusive allowlist — a declared tools[] yields BASE ∪ (declared ∩ CREATOR_SELECTABLE_TOOLS), and BASE (:15-70) contains no terminal_*. resolveToolPolicy (instance-tool-policy.ts:132) therefore stamps not_declared, and the invoker refuses at routes/tools.ts:153-157.

Neither half is wrong. The composition is: the tab's write controls correctly disappear, and its read calls correctly 403. The surface still renders because surfaces.tsx:194-199 gates the tab on surfaces.includes("tmux") alone, with no tool check — which is #403's deliberate choice, and the right one.

It is not a rename — three shape changes, not one

Worth stating plainly, because "just swap the names" will not compile into working behaviour:

terminal_* tmux_*
target arg target: "tmux:main" (backend-prefixed), requireTarget (terminal.ts:27-30) session: "main", requireSession (tmux.ts:28-31)
create {backend, name, workDir, command}, required:["backend"] (terminal.ts:147-155) {session, workDir, command}, required:["session"] (tmux.ts:139-147)
list rows /terminal/list → {backend, id, …} /tmux/list → {name, windows, attached, activeCommand, activeWindow, created} — no backend, no id (browser-runner/src/coding/tmux.ts:96-118, server.ts:302-304)

The last row is the trap: parseTargets (TmuxTab.tsx:56-72) drops any row lacking both backend and id (:59-61). A naive name swap yields a silently empty target list — a worse failure than the 403, because it looks like a working tab with nothing on the machine.

Blast radius — one correction to the hand-off

The hand-off said "one user, and the kitty/iTerm2 rows are draft". The first is true today; the second does not bound it, because tmux-operator itself is published. Measured against production D1 and the public catalog:

slug visibility instances users
tmux-operator published 2 1
terminal-operator draft 1 1
kitty-operator draft 1 1
iterm-operator draft 1 1

GET https://api.proagentstore.online/v1/agents returns tmux Operator among 15 published agents. So this is not confined to the author's two instances: it is a live catalog entry, and the next person to subscribe gets a Terminal tab that has never worked. All four rows declare surfaces:["tmux"], so all four render the tab — but only tmux-operator's tools change, so the other three keep working. Blast radius is exactly tmux-operator's instances, present and future.

One thing 0099 already gets right and should not be undone: its instance_connector_consent carry. Instance cda75e28 holds terminal write consent but no tmux row (writeConsent: required on tmux_* today); 1a8486b5 holds both. The migration's INSERT OR IGNORE covers the first, so once the tab is fixed the writes work without the owner re-granting anything. Verified live.

What to do — cheapest first

(a) The fix that should ship with #403 — teach the tab the tmux_* family

Not a rename; the three shape changes above. Confine the backend vocabulary to tmux while doing it (the create-target form's backend picker at TmuxTab.tsx:111,261 has nothing to choose between).

(b) The part that makes it not happen again — gate the reads the way the writes are gated

Extend 1f3ca00's promise to the two read calls: consult allowedTools before firing refreshTargets/capture, and when the tool is absent render why rather than the transport error. McpConnections.tsx:214-226 is the pattern already in the tree — it hardcodes mcp_call_tool but only offers the button once the report says that tool is callable, and its header states the reason. Same discipline, applied to a tab.

This also repairs the false empty state: with the reads gated, targetCount === 0 no longer means "the machine has no terminals" (tmuxView.ts:187,193-195).

(c) Do not wait for #402

#402's backends ceiling would let the tmux Operator go back to terminal_* constrained to tmux, making (a) moot — but #402 sits on #404, the constraints primitive, which is in flight now. Shipping 0099 and leaving the tab broken until a two-ticket chain lands puts a published agent in the catalog with a dead tab for the duration. (a) is small, and it is not wasted: if #402 later restores terminal_*, the tab's tool names change back in one commit, while (b) — the gate — is what #402 needs anyway, since a constrained agent can still lose a tool.

Alternatives considered and rejected

The generalisation — is any other console surface exposed?

Grepped; today the answer is: two call sites, and only one is unguarded.

$ grep -rnoE '"(terminal|tmux|github|http|web_search|meta|browser|mcp)_[a-z_]+"' store/console/src agents/coder/web/src

Excluding tests, every hardcoded registry tool name in the console tree is in TmuxTab.tsx (10 occurrences, lines 149/169/205-208/226/241/261/281). The only other surface that invokes the generic tool route is McpConnections.tsx:226 (mcp_call_tool) — and it is already correct: guarded on the live callability verdict from /mcp/test, with the reasoning in its header at :214-221.

Every other console consumer of /v1/instances/:id/tools reads the list generically, never by name — ToolPermissions.tsx:45,62, TeamworkSection.tsx:126, lib/toolPolicy.ts, lib/teamwork.ts:292. And lib/surfaces.tsx derives tab visibility from declared tools (surfaces.test.ts:97-120), which is the robust direction: it reads the declaration rather than assuming it.

So the defect class has a population of one, and this is the moment to keep it that way. The exposure is not "the console hardcodes names" — that is unavoidable for a tab built on a specific connector. It is a hardcoded name with no check that the agent still declares it. Proposed ratchet, following the source-scanning precedent already in this tree (lib/control-shapes.test.ts, lib/designTokens.test.ts, tabs/BehaviourTab.test.ts all read source):

A test that scans the console tree for string literals matching a registry connector tool name, and asserts each one is either (i) reached only behind an allowedTools/verdict check, or (ii) listed in a small explicit exemption set. Failing closed on a new hardcoded name is the point — this regression was authored in 6cbd8dc, five days before the migration that broke it, and nothing objected.

Pair it with the server-side half in the #403 branch's own tmux-operator-seed.test.ts, which already resolves the seeded list through the real registry and real toolNamesFor — it verifies the agent end and stops at the console boundary.

Acceptance criteria

  • With 0099 applied, opening Terminal on a tmux-operator instance with a live runner lists tmux sessions and captures a pane. Verified on cda75e28-cace-4958-ac3e-6a7528e6b719.
  • Session rows render with a name (parseTargets no longer discards rows for a missing backend/id), i.e. the list is non-empty when tmux has sessions.
  • Run-command, send-keys, new-session and kill-session work on both live instances without the owner re-granting write consent (0099's consent carry covers it).
  • When a tool the tab needs is genuinely absent, the tab says which tool and why — not a 403 string, and not "No terminal targets found on ".
  • No terminal_* literal remains in TmuxTab.tsx; the backend picker is gone or fixed to tmux.
  • kitty-operator, iterm-operator and terminal-operator are unchanged — they keep terminal_* and their tab still works.
  • A guard test fails if a new hardcoded registry tool name appears in the console tree unguarded.

Regression risk

Open question for the owner

Should tmux-operator be moved to draft until the tab is fixed? It is the only published row of the four and the only one 0099 touches. I would not unpublish: there is exactly one subscriber (you), the tab is broken rather than harmful, chat-driven tmux keeps working and gets more correct, and unpublishing/republishing a catalog entry is more moving parts than a console fix that is a day's work. But it is a catalog-honesty call, and it is yours.


Files: store/console/src/tabs/TmuxTab.tsx:56-72,111,121-137,149,169,202,205-208,226,241,261,281, store/console/src/lib/surfaces.tsx:194-199, store/console/src/lib/tmuxView.ts:180-195, store/console/src/lib/pollBusy.ts:109-113, store/console/src/components/McpConnections.tsx:214-226, workers/api/src/routes/tools.ts:143-163, workers/api/src/lib/instance-tool-policy.ts:120-145, workers/api/src/agent-do-tools.ts:15-70,187-193, workers/api/src/lib/tool-refusal.ts:24-28, workers/api/src/lib/connectors/tmux.ts:28-31,36-63,133-147, workers/api/src/lib/connectors/terminal.ts:27-30,41-58,141-156, packages/browser-runner/src/coding/tmux.ts:96-118, packages/browser-runner/src/server.ts:302-304, workers/api/migrations/0099_tmux_operator_backend_exclusive_tools.sql (unpushed), workers/api/src/lib/tmux-operator-seed.test.ts (unpushed), docs/capability-constraints.md:41-42,111-112. Related: #403 (the delivery that lands this), #402 (the backends ceiling that would make fix (a) moot), #404 (the constraints primitive #402 sits on), #378 (the error-slot fix this must not undo), #351 (the writeConsent verdict this tab reads), commits 6cbd8dc (converted the tab tmux_* → terminal_*) and 1f3ca00 (established per-tool gating of the write controls).

Activity

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Fixed in 782f00c, together with #403 (78d2cfe) — the two shipped as one unit precisely so the migration never landed without the console fix. CI green, migration 0099 applied to the remote D1.

    You were right that this is not a rename. The two families differ in four shapes, and /tmux/list rows carry no backend/id, so a name swap would have produced a silently empty list — a worse failure than the 403. All of it now lives in a pure module store/console/src/lib/terminalTools.ts (families + argument builders + resolveTerminalAccess), with TmuxTab.tsx reduced to glue.

    The family is resolved from GET /tools?allowed=true, not from a slug — three of the four Operators keep the generic connector, and a test asserts their copy is byte-identical. Both shipped promises survive: 1f3ca00's per-tool gating now extends to the two reads (still four separate write verdicts, not one canWrite), and #378's rule holds — a predictable refusal is never written to the error slot, so the notice is derived from policy and stands across polls. With no family resolved, nothing is polled at all, which stops the 4s refusal storm you identified. loading is a first-class state so "this agent has no terminal tools" is never rendered and then withdrawn.

    The ratchet (store/console/src/lib/toolLiterals.test.ts) asserts four things: the vocabulary is derived from the connector definitions and is non-vacuous (a guard whose vocabulary silently emptied would pass forever); every hardcoded connector tool name in store/console/src + agents/coder/web/src is pinned per name with a stated reason; TmuxTab.tsx names no tool at all; and every name in the family table is a real registry tool, so a server-side rename fails here rather than at a user's tab. Proved it bites by adding a terminal_capture literal back — two assertions failed with file:line.

    A correction to your grep: it matched only names filling a whole string literal, which is why it found a population of one. Scanning for the name anywhere finds two more — McpConnections.tsx keeps mcp_call_tool inside a URL path (already guarded), and two files mention a tool in copy. None is a call; all four are pinned with reasons rather than quietly absorbed.

    The runner was deliberately not touched. The tmux rows get their backend in the console, because pags up is an installed npm package on someone else's machine — a fix requiring a new runner does not reach the person with the bug until they upgrade.

    The open question dissolves: the tab works, so there is nothing to unpublish. tmux-operator's visibility is unchanged.

    Verified: full unit bar, plus e2e in Chromium and WebKit at 320px and 390px on a tmux_*-only fixture using the runner's real row shape, and the unsupported state asserting the notice stands across 5.5s of polls with zero tool calls made. The pre-existing #370/#378 terminal block re-run 12/12, unregressed.

    Left to a live check: open the Terminal tab on the tmux Operator with pags up running and confirm it lists real sessions and captures a pane. Leaving open until you have.

  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Verified and closing — including the live check that was left open

    Independent verification of 782f00c (with 78d2cfe, which carries migration 0099). The previous comment left this open pending "open the Terminal tab with pags up running". That check has now been done against production, through the real gate and the real runner, and every acceptance criterion holds.

    Criterion by criterion

    1. With 0099 applied, Terminal on a tmux-operator instance with a live runner lists sessions and captures a pane — on cda75e28-…. ✅ Verified LIVE. GET /v1/instances/cda75e28-…/runtime/status → online, node Mac, runner 0.4.40. Then, against the real route with the real gate, using the exact argument shapes lib/terminalTools.ts now builds:

    call args sent result
    tmux_new_session {session, workDir} (createArgs, no backend) 200 Created tmux session "pags-verify-409" in /tmp.
    tmux_list_sessions {} 200, one row: {"name":"pags-verify-409","windows":1,"attached":false,"activeCommand":"zsh","activeWindow":"tmp","created":"1786160412"}
    tmux_run_command {session, command} (runArgs) 200, pane echoed the command
    tmux_capture_pane {session, lines:500} (captureArgs) 200, pane contents returned
    tmux_send_keys {session, text, keys:"Enter"} — comma-string (sendKeysArgs) 200, keys landed
    tmux_kill_session {session} (killArgs) 200 Killed tmux session "pags-verify-409".

    The session was created and killed by this check; tmux_list_sessions is back to []. Control: POST …/tools/terminal_capture on the same instance still returns 403 "terminal_capture" is not one of this agent's tools — the family really did move, and the tab really is calling the other one.

    2. Session rows render with a name — parseTargets no longer discards rows for a missing backend/id. ✅ Verified against the live runner's own output, not a fixture. That row above has no backend and no id — exactly the trap. Fed through the shipped parseTargets(TMUX_FAMILY, …) it yields [{"backend":"tmux","id":"pags-verify-409","name":"pags-verify-409","windows":1,…}], key tmux:pags-verify-409. Non-empty. store/console/src/lib/terminalTools.ts:271-273 is where the backend is supplied console-side.

    3. All four writes work on both live instances without re-granting consent. ✅ GET /tools on both tmux-operator instances (cda75e28-…, 1a8486b5-…) reports writeConsent: granted for tmux_run_command, tmux_send_keys, tmux_new_session, tmux_kill_session — nothing was re-granted. Three of the four were then actually executed on cda75e28 (table above); 1a8486b5's were verified by consent verdict only, not executed.

    4. An absent tool says which tool and why — not a 403 string, not "No terminal targets found". ✅ lib/tmuxView.ts:201-215: the unsupported branch outranks offline and prints "This agent doesn't have the tool this tab needs (terminal_list_targets or tmux_list_sessions). An agent's creator declares which tools it has — Settings › Tools lists them.", with empty states "This agent has no terminal tool, so nothing was asked of your machine." Also verified by e2e (below), including that the notice stands across polls and that no tool call is made at all in that state — the 4s refusal storm is gone.

    5. No terminal_* literal in TmuxTab.tsx; the backend picker is gone. ✅ Ran the shipped findToolLiterals over the real TmuxTab.tsx against the derived 43-name vocabulary → []. Picker: TmuxTab.tsx:456-463 renders it only when backends.length > 1, and TMUX_FAMILY.backends === ["tmux"], so it is absent for this agent and unchanged for the other three.

    6. terminal-operator, kitty-operator, iterm-operator unchanged. ✅ Verified LIVE against production D1 via GET /v1/agents/my/agents — all three agent rows still declare the six terminal_* tools, all still draft; tmux-operator alone declares the six tmux_* and is published. GET /v1/instances/da39100b-…/tools confirms terminal_* = allowed=true reason=ok and tmux_* = not_declared. The family is resolved from the declared set, not from a slug (resolveTerminalAccess, terminalTools.ts:132-137), the generic family is first in preference order (:104), and the copy is derived from family.backends rather than switched on the id (tmuxView.ts:169-171) so the three Operators' sentences come out byte-identical. Covered by terminalTools.test.ts:41-47 and :102-110.

    7. A guard test fails if a new hardcoded tool name appears unguarded. ✅ store/console/src/lib/toolLiterals.test.ts exists — and I proved it bites rather than taking it on trust, without editing the tree: running the shipped toolVocabulary/findToolLiterals out of a scratch copy,

    • vocabulary = 43 names derived from the connector definitions (the non-vacuity assertion is real);
    • a hypothetical new console file containing callTool("terminal_capture") → [{line:1,name:"terminal_capture"}], i.e. a stray not in ALLOWED → the "every hardcoded tool name is in a file that is allowed to hold one" assertion fails;
    • TmuxTab.tsx with one literal prepended → a finding → the "TmuxTab names no tool at all" assertion fails.

    Migration 0099 is applied in production

    Not merely merged. GET /v1/agents/my/agents shows the tmux-operator agent row carrying capabilities.tools = [tmux_list_sessions, tmux_capture_pane, tmux_run_command, tmux_send_keys, tmux_new_session, tmux_kill_session], surfaces:["tmux"], published; both instances resolve the same. The instance_connector_consent carry is live (criterion 3).

    Tests re-run here

    pnpm vitest run store/console/src/lib/{toolLiterals,terminalTools,tmuxView,pollBusy,surfaces}.test.ts
     ✓ pollBusy (22)  ✓ terminalTools (24)  ✓ tmuxView (32)  ✓ toolLiterals (7)  ✓ surfaces (18)
     Test Files 5 passed (5) | Tests 103 passed (103)
    

    Playwright, Chromium and WebKit at 320px and 390px:

    ✓ [chromium|webkit] lists tmux sessions and captures a pane at 320px / 390px
    ✓ [chromium|webkit] the create row offers no backend to choose, and the write controls are live
    ✓ [chromium|webkit] an agent with no terminal tool says which tool, and never blames the machine
      (plus the 4 #405 specs in the same run) — 14 passed
    

    CI on 782f00c: CI ✅, Deploy API ✅, Deploy Host ✅.

    What I could NOT verify

    • The rendered tab in a real browser against production. No browser here. The render is covered by the e2e above (both engines, both widths) against a tmux_*-only fixture using the runner's real row shape, and the API half is covered live — but nobody has literally opened proagentstore.online/console on this instance and looked at it.
    • 1a8486b5-…'s write tools were not executed, only their consent verdict read. cda75e28 is the instance the criteria name and it was exercised fully.
    • The runner on this machine is 0.4.40. Irrelevant here — [bug] #403 breaks the Terminal tab: the console calls terminal_* by name, and the tmux Operator will no longer declare them #409 deliberately did not touch the runner, which is why the tmux rows get their backend console-side.

    Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions