[bug] The capability-constraint gate opens when there is no authority, refuses a target its own ceiling permits, and no agent declares targets:"single" #441
Description
Three residuals from #404 and #402 — the ceiling opens when there is no authority, refuses a target it permits, and is declared by nobody
#404 shipped the capability-constraint gate and #402 added the binding half. Both are live and both
are right in the case they were built for. Three things sit just outside their acceptance lists.
1. The gate is SKIPPED when there is no authority — the opposite of every neighbouring gate
workers/api/src/lib/tool-registry.ts:467-486:
if (tool.connector && CONNECTOR_CONSTRAINTS[tool.connector]) {
const authority = consentInstanceOf({ instanceId: ctx.instanceId ?? "", … });
if (authority) {
…
const gated = enforceConstraints(tool, spec, callInput);
if (!gated.ok) return { name, content: gated.refusal, success: false };
}
}No authority → the whole block is skipped and the call runs unconstrained. Compare the two gates
immediately above it, in the same function:
- write-consent (
:432-441) resolves the same authority and passes it to
hasConsent(env, authority || undefined, …), which returns false → refused. Fail-closed, and
the comment says so: "Fail-closed — no connector, no instance context, or no consent → refused." - the constraint read itself (
:471-482) fails closed on a throw, with the reasoning spelled
out: "A ceiling that cannot be read cannot be honoured, and a boundary that opens when its store
hiccups is not a boundary."
So the same function refuses when the ceiling can't be read, and waves through when there's nobody
to read it for. Neither direction is tested — tool-registry.test.ts has no case for a ctx without
an instanceId.
There is a live second door to the same place. agent-think.ts:945,962 passes
instanceId: state.agentId — for the agent-TEMPLATE surfaces (/v1/agents/:id/chat, the public
trial) that is an agent id, not an instance id. connectorConstraintsForInstance joins
agent_instances i JOIN agents a ON a.id = i.agent_id WHERE i.id = ?1 and returns undefined when
no row matches (lib/agent-capabilities.ts:561-584), and enforceConstraints with spec === undefined returns the input unchanged. So a creator's own trial chat of a kitty-operator is not
subject to the ceiling that agent declares — while the write-consent gate, on the identical input,
refuses. Practically inert today (that surface has no runner bound), but it is the rule not
applying rather than the rule permitting.
What to do. Decide the posture once and write it down, then make both paths match it. My
recommendation: fail closed, i.e. a connector that HAS a constraint vocabulary and a call with no
resolvable authority is refused, with the same wording as the unreadable-spec branch. It costs
nothing today (every real surface has an authority) and it means the gate cannot be reached by a
future call path that forgets to pass one — which is exactly how the #402 disclosure happened.
Whichever way it goes, add the two tests (no authority, authority that is not an instance), and
say in the comment why.
2. A multi-value ceiling refuses a target it explicitly permits
enforceConstraints checks a prefixed target first (surface-options.ts:463-476) and then falls
through to the backend argument rule, which does not know what the prefix just told it.
Measured, running enforceConstraints + parseConstraintSpec from main directly:
spec {"backends":["tmux"]} input {"target":"tmux:main"}
→ OK input={"target":"tmux:main","backend":"tmux"} ← narrowed, correct
spec {"backends":["tmux","kitty"]} input {"target":"tmux:main"}
→ REFUSED "…`terminal.backends` (tmux, kitty): `all` is not available to this
agent — pass `backend` explicitly — one of tmux, kitty." ← the bug
spec {"backends":["tmux","kitty"]} input {"target":"iterm2:1:1:1"}
→ REFUSED "…names a terminal backend this agent may not use." ← correct
spec {"backends":["tmux","kitty"]} input {"backend":"tmux","target":"main"}
→ OK ← correct
The refused call named tmux unambiguously, in the argument the tool actually acts on, and tmux
is permitted. The message is also wrong twice over: it says all is not available when the caller
never used the wildcard, and it asks for a value the caller already supplied in another field.
Mechanism. The prefix loop continues on a permitted prefix (:474) instead of recording it, so
the later branch sees backend absent, and allowed.length !== 1 so it cannot narrow. The
single-value case works only because narrowing to allowed[0] happens to be the right answer there.
Fix. When a prefixArgs value carries a permitted prefix, treat that as the named value: narrow
def.arg to it and skip the "pass it explicitly" branch. Keep the refusal for a prefix outside the
ceiling (already correct) and for a genuinely unspecified call (also correct).
Reachability, stated honestly. No agent in production declares a multi-value ceiling — the only
two declarations are single-valued (kitty-operator → ["kitty"], iterm-operator →
["iterm2"], migration 0104), and parseConstraintSpec drops a list covering the whole
vocabulary. So this is latent: it fires the first time a creator declares a 2-of-3 subset, which
the update route accepts today. That is a cheap fix now and a confusing refusal later.
3. targets: "single" has never been exercised end to end
#402's criterion 2 is built, routed and unit-tested at the dispatcher — the binding parses
(parseConstraintSpec), narrows (narrowConstraintSpec), is writable
(PUT /v1/instances/:id/terminal-target, routes/instances-terminal.ts) and is enforced
(enforceBinding in runRegistryTool). Nothing declares it.
Verified against production (GET /v1/agents/my/agents, 39 agents): exactly three carry
surfaceOptions at all —
kitty-operator draft {"terminal": {"backends": ["kitty"]}}
iterm-operator draft {"terminal": {"backends": ["iterm2"]}}
coder-repo published {"coding": {"repos": "single", "drive": false, "copilot": false}}
— and migration 0104 says so deliberately: "Neither row is given targets: "single": these are
backend test agents, not named single-pane operators." That was the right call for those two rows.
The consequence is that no single-declaring agent has ever run: nobody has bound a target through
the route, seen the "you must bind one" refusal, or watched a bound target survive a dispatch.
What to do. Give one agent the declaration and drive it once. A named single-pane operator is
the shape #402 describes; the cheapest honest version is a draft agent (like the two above) with
{"terminal": {"backends": ["tmux"], "targets": "single"}}, then:
bind via PUT …/terminal-target, call terminal_capture with and without a target, and confirm
(a) unbound + single → refused rather than guessing a pane, (b) bound → the bound pane, (c) a
different pane → refused. Record the four outcomes on the ticket. Fixing 2 first is worth it:
this exercise is the natural place it would have been noticed.
Alternatives considered and rejected
- Fail OPEN on no authority, and document it. Defensible — an unauthenticated caller reaches no
runner anyway — but it makes this the only permission-shaped check in the function with that
posture, and the value of a gate is that its posture is predictable. If the owner prefers it,
it must be asserted by a test, not left as anif. - Canonicalise a prefixed target into
backendat the route/tool boundary instead of in the
gate. Rejected:instances-terminal.tsdeliberately stores what the owner typed
("rewriting the owner's input would only make the stored config disagree with what they set"),
and the gate must be safe against a config written by something other than that handler. - Refuse multi-value ceilings entirely (allow only a single backend). Rejected:
narrowConstraintSpec
and the whole vocabulary are built around a subset, and "two of three" is a legitimate thing for a
creator to mean. - Ship a
targets: "single"declaration ontmux-operator. Rejected for0104's own reason:
since0099it declarestmux_*tools whose connector istmux, which has no constraint
vocabulary, so the field would be dropped by the sanitiser on the next write.
Acceptance criteria
- A dispatch with no resolvable authority has one defined, tested behaviour, matching the
documented posture of the neighbouring gates. - A dispatch whose authority is an agent id rather than an instance id behaves the same way.
-
enforceConstraints({backends:["tmux","kitty"]}, {target:"tmux:main"})→ ok, with
backendnarrowed totmux; the three other measured cases above are unchanged. - One agent declares
targets: "single", and the four end-to-end outcomes are recorded on this
issue.
Regression risk
- Fail-closed on missing authority is a behaviour change for any caller that has been getting away
with it. Grep everyrunRegistryToolcall site for a ctx withoutinstanceIdbefore flipping it —
today they all have one, and that must be re-checked, not assumed. enforceConstraintsreturns the input byte-identically when nothing is declared
(surface-options.ts:443-445, asserted insurface-options.test.ts). Narrowing on a prefix must
not disturb that: it only applies when a ceiling exists AND a prefix arg named a permitted value.matchesBoundTarget's rule that two different prefixes never match must survive any change to the
prefix handling — that is what stops a command being redirected onto another backend instead of
refused.
Files: workers/api/src/lib/tool-registry.ts:418-486, workers/api/src/lib/surface-options.ts:380-505,
workers/api/src/lib/agent-capabilities.ts:556-584, workers/api/src/agent-think.ts:941-965,
workers/api/src/routes/instances-terminal.ts, workers/api/migrations/0104_operator_backend_ceilings.sql,
workers/api/src/lib/surface-options.test.ts, workers/api/src/lib/operator-backend-ceiling.test.ts.
Related: #404 (the gate), #402 (the binding), #403/#99 (the tools-as-declaration
route this complements), #185 (executor-not-asker, which the authority resolution implements).
Activity
fix(tools): the capability-constraint gate refuses a call it cannot r…
fix(constraints): a prefixed target narrows the backend it names, so …
feat(agents): the Single-Pane Operator is the first agent anywhere to…
serge-ivo commentedon Aug 8, 2026
Shipped in
efe8d5f(fail-closed gate),dbfad93(prefix narrowing) andb6f6c9f(thesingledeclaration + migration0112), all onmain. CI green,Deploy API Workergreen,d1 migrations apply pags --remotereports0112_seed_single_pane_operator_agent.sql ✅.All three parts were verified against the code before being changed, not taken from the report. I reproduced §2 by driving the real
parseConstraintSpec+enforceConstraintsfrommain— all four measured cases came back exactly as the issue states, including the refusal that namesallwhen the caller never used a wildcard.1 — the gate now fails closed in both its directions
The distinction that made this fixable is that
undefinedhad two meanings.lookupConnectorConstraints(new,lib/agent-capabilities.ts) returns a discriminated{instance:"found", spec}/{instance:"missing"}, so the gate can refuse a ceiling it cannot locate while leaving a row that exists and declares nothing completely alone — that second case is every agent on the platform bar three, and it is byte-identical to before #404 existed.connectorConstraintsForInstancesurvives as a thin wrapper forroutes/instances-terminal.ts, which has already established the instance.The second door is closed at the gate, not at the caller.
agent-think.tswas deliberately not touched: a rule that holds only where the caller remembered to pass the right kind of id is not a rule, and that file was contended today. The agent-template chat surfaces still pass an agent id; the join misses; the call is now refused instead of running unconstrained.Call-site audit, re-checked rather than assumed, as the regression note demanded:
agent-think.ts:941,:958instanceId: state.agentId/v1/agents/:id/chat+ the public trial → join misses → now refused (was silently unconstrained)lib/pipeline.ts:514,523PipelineRunCtx.instanceId, a required fieldlib/steps.ts:669,718,802routes/tools.ts:160,650instanceIdafterrequireOwnedInstanceBlast radius stated honestly: only a connector present in
CONNECTOR_CONSTRAINTSreaches this gate, and today that isterminalalone. Every other connector skips it entirely. Tests cover no-authority → refused, agent-id-as-authority → refused, and — the one that matters most — row-exists-declaring-nothing → still runs.One fixture outside the issue's file list needed a 6-line change:
lib/connectors/terminal.test.ts'senvConsentanswerednullto every query including the constraint join, so its two write-consent cases would have started failing for a reason unrelated to consent. It now tells the two queries apart. NoCONNECTOR_CONSTRAINTStable entry was touched — that is #447's, and it is live.2 — a permitted prefix is now recorded, not discarded
enforceConstraintsrecords a permitted prefix and narrowsdef.argto that rather than to the ceiling's only member — the two coincide under a single-value ceiling, which is exactly why this hid. Asserted for all four measured cases plus a fifth: an unprefixed target under a multi-value ceiling still asks rather than guesses. The byte-identity guard andmatchesBoundTarget's two-different-prefixes rule are untouched.3 —
targets: "single"is declared, and here are the four outcomesMigration
0112seeds a draftsingle-pane-operatorcarrying{"terminal": {"backends": ["tmux"], "targets": "single"}}, toolsterminal_list_targets/capture/run_command/send_keys— deliberately notnew_target/kill_target, since #402 is explicit that "may not create a terminal" is expressed by not declaring the tool. NoboundTarget: unbound is the interesting state, and the bound identity is the subscriber's half.Proved by resolving that literal through the real parser, the real sanitiser and the real dispatcher gate rather than trusting the SQL:
sanitizeDeclaredCapabilitiessingle→ refused, not guessedmaincanonicalises totmux:mainConfirmed in production: the draft does not appear in
GET /v1/agents(15 published, unchanged), so nothing leaked into the catalog.What is NOT done, and why this stays open
The live drive.
PUT /v1/instances/:id/terminal-targetagainst a real subscribed instance withpags uprunning, and the refusals arriving in a real chat turn, needs a machine with a tmux runner attached — I have the row in production but not the runner. The tests cover everything bar the D1 read and the runner hop, and criterion 4 asks for the four outcomes recorded from a real drive. Leaving this open for that.Two smaller notes:
docs/capability-constraints.mdhas no paragraph on the fail-closed posture. It is written down in the gate's own comment, which is where the neighbouring gates document theirs, so the AC's "documented posture" is met — but the published doc is the better home and this is a two-sentence addition.tmuxkey lands inCONNECTOR_CONSTRAINTS, the fail-closed branch shipped here starts applying to thetmux_*tools too. That is correct and intended, but it is a live behaviour change for the publishedtmux-operatorand its two instances, so [design] tmux is the one Operator connector with no capability ceiling — CONNECTOR_CONSTRAINTS has a single key, and #403 moved the published agent off it #447 should re-check that its dispatch paths carry an instance id.docs(constraints): the capability gate's fail-closed posture is writt…
serge-ivo commentedon Aug 8, 2026
The doc paragraph landed (
cc84f51). The live drive is still the blocker, and I could not do it — no runner is connected.Picked this up to finish the two items your 13:10 comment left. One is done; the other is not doable from here.
Done —
docs/capability-constraints.mdnow states the fail-closed postureYour comment noted the published doc had no paragraph on it, and that the gate's own comment met the AC's "documented posture" but was not the better home. Added, and verified against
lib/tool-registry.ts:470-506andlookupConnectorConstraintsrather than transcribed from this thread:agent_instancesjoin does not match → refused, row present and declaring nothing → runs, byte-identical to before [design] Capability constraints — declare what an agent may PASS, and enforce it at the tool boundary #404;CONNECTOR_CONSTRAINTSreaches the block at all, so adding a key to that table turns the posture on for that connector's tools, and its dispatch paths need an instance id first. That is your [design] tmux is the one Operator connector with no capability ceiling — CONNECTOR_CONSTRAINTS has a single key, and #403 moved the published agent off it #447 coordination note, now in the doc where whoever does [design] tmux is the one Operator connector with no capability ceiling — CONNECTOR_CONSTRAINTS has a single key, and #403 moved the published agent off it #447 will actually read it.Docs only. No behaviour change.
Not done — criterion 4's live drive
PUT /v1/instances/:id/terminal-targetagainst a real subscribed instance with the refusals arriving in a real chat turn. Checked before attempting:No relay socket is up, so a
terminal_*dispatch has nothing to reach and the four outcomes cannot be observed end to end. I did not start one — perpags/CLAUDE.mdan agent does not run the runner itself, andpags upis the owner's machine.Two things I did confirm live, since they were cheap and are claims in this thread:
single-pane-operatorexists on the account carrying{"terminal": {"backends": ["tmux"], "targets": "single"}}, as migration0112seeds it;GET /v1/agentsreturns 15 published agents and none is it. Nothing leaked.So the state is exactly as you left it: everything bar the D1 read and the runner hop is covered by tests, and the ticket is waiting on a machine with a tmux runner attached. Needs the owner, not an agent.
feat(capabilities): tmux gets a constraint vocabulary, so the one PUB…
serge-ivo commentedon Aug 11, 2026
#447 turned this posture on for a SECOND connector, and the ticket predicted the opposite — measured: the capability did not change, the message did
4fda69dadded atmuxkey toCONNECTOR_CONSTRAINTS. #447's regression section said shipping thevocabulary would be inert for the two live instances; the commit message says it is not, and no
ticket carries that. Filed here rather than as its own issue: this is §1's second door, on a
connector that has just joined the table, and the one open decision is a change to the gate this
issue rewrote.
Verified against
mainand against production, because "live regression" and "latentinconsistency" want different answers.
1. The lookup really is skipped for a connector absent from the table
workers/api/src/lib/tool-registry.ts:548:Every part of the posture sits inside that
if: the throw-refusal (:552-562), the missing-rowrefusal (
:564-570),enforceConstraints(:571). So whiletmuxhad no key, none of #441applied to
tmux_*at all. The table now has two keys —terminal(surface-options.ts:214) andtmux(:235-250) — so it does.2. Seven tools, not the six #447 described
migrations/0117_tmux_operator_send_message_tool.sql:27-33declares seven on the publishedtmux-operator;#482addedtmux_send_messageafter #447 was written.lib/connectors/tmux.ts:36, 51, 79, 106, 135, 173, 205.One thing worth not confusing: the missing-row refusal (
:564-570) runs BEFOREenforceConstraints(
:571), so "tmux_list_sessionsstays ungated" holds only for the LOCATED case. Under anunresolvable authority it is refused with the other six — correctly, because that refusal is about
locating the ceiling, not about the argument.
3. The surface:
state.agentIdhanded to an instance-shaped parameteragent-think.ts:1007and:1024(they were:945,:962when you filed this) passinstanceId: state.agentId. An instance DO is initialised withagentId: instanceId(
routes/instances.ts:215), so that field is an instance id there — but the template DO is keyed bythe agent row (
routes/chat.ts:37,48), and the public trial DO copies template state undertrial:{agentId}:{sid}(routes/public.ts:109-124).lookupConnectorConstraintsjoinsagent_instancesonly (lib/agent-capabilities.ts:596-608) →{instance:"missing"}→ refused.The asymmetry is worth naming, because it is the thing that makes this surface look supported:
resolveAgentCapabilities(agent-think.ts:61-75) tries the instance join and then theagentsrow, so the template surface IS offered the agent's full declared tool list and the model can call
all seven. The same id resolves the tools and cannot resolve their ceiling.
4. Measured — before vs after, all seven, on that surface
Driving the real
runRegistryToolwithinstanceId= an agent id (a D1 that answers nothing to thejoin) and the runner mocked absent. "Before" = the same tree with the
tmuxkey deleted fromCONNECTOR_CONSTRAINTS, which is the pre-4fda69dtable exactly — that key is the only difference.tmux_list_sessionspags up…"tmux_capture_panetmux_run_commandtmux_send_keystmux_send_messagetmux_new_sessiontmux_kill_sessionThe five writes are unchanged because the write-consent gate answers first
(
tool-registry.ts:496-507) and noinstance_connector_consentrow can be keyed by an agent id —setConsent's only caller is behindrequireOwnedInstance(routes/tools.ts:438). Two readsswapped one failure message for another.
5. In practice this surface is inert — which is why it is §1's door, not a regression
The two reads could not have reached a pane before the change either.
resolveRunner(
lib/connectors/tmux.ts:20-25) →getBoundRunnerConn(lib/runner-client.ts:74-107) resolvesinstance_runtimes/instance_runtime_nodesby instance id, and the only writer of those rowsis
POST /v1/instances/:instanceId/runtimebehindrequireOwnedInstance(
routes/instances.ts:330-333,routes/instances-runtime.ts:671-682), which readsagent_instances. An agent id can never appear there.Measured live (
GET /v1/terminals/nodes, read-only): 37 registered runtime ids across theaccount, none equal to the
tmux-operatoragent id2dff5c62-59f0-4d2d-b0a6-b1db5c879c46; bothits instances —
1a8486b5-…"tmux Operator" andcda75e28-…"Heartfull (tmux)" — are registered andconnected. Neither declares
surfaceOptions(GET /v1/agents/my/agents), so §5-of-#447's promise tothem holds: a row that exists and declares nothing still runs.
Verdict: not a live regression. No capability was lost, nothing that worked stopped working, no
ceiling stopped applying, and the direction of the change is toward the posture, not away from it.
What changed is the wording on a surface where no tmux work is possible.
What is left — two small things, both in this issue's own files
refusing, and add one line to
docs/capability-constraints.mdsaying so — a dispatch thatnames no instance has no subscriber half to narrow, and "refused" is the honest answer.
Option B: give
lookupConnectorConstraintsthe second queryresolveAgentCapabilitiesalready has (fall back to the
agentsrow → the CREATOR's ceiling,requestedempty), whichwould make a template trial subject to the declared ceiling instead of refused, and restore
the runner message underneath it. Rejected as the default because it reintroduces "the gate
must know which kind of id it got", which is the shape §1 argued against — but it is the
better answer if a ceiling-governed connector ever becomes cloud-backed, because then the
template surface would have real work to constrain. Either way the current message is the weak
part: a creator trialling
tmux-operatorreads "declared constraints … could not beresolved" about an agent that declares none, when the true answer is "no runner, and this
surface can never have one".
docs/capability-constraints.mdis stale in the section this issue wrote. The Shape block(
:99-103) still shows onlyterminalandPUT /v1/instances/{id}/terminal-target, while/v1/instances/{id}/tmux-sessionis mounted (routes/instances-terminal.ts:58-59) and is instore/openapi.yaml.:134-137warns that "adding a key to that table therefore turns theposture on for that connector's tools, and its dispatch paths must be checked for an instance
id first" — that has now happened once, and naming
tmuxas the worked example (with thetable above) is two sentences.
Verified vs inferred
Verified: the skipped lookup, the two-key table, the seven tools, the two DO id shapes, the
before/after table (measured), the 37 live runtime ids, and that no consent or runtime row can carry
an agent id.
Inferred, from a re-audit of today's tree rather than a guarantee: that no other call path passes
a non-instance authority. Five sites on
main—agent-think.ts:1001,1018(agent id),pipeline.ts:514,523viaregistryCtxat:490(the run'sinstanceId),steps.ts:669,718,802(forwards the dispatching ctx),
routes/tools.ts:160,679(afterrequireOwnedInstance) — onlyagent-think's two are agent ids. That is exactly the property §1 said must be re-checked and not
assumed, and it will need re-checking again the next time a key joins the table.
Regression risk. Doing nothing: none observed. Option B: it changes
tool-registry.test.ts:334("refuses an authority that is an AGENT id rather than an instance id") from the property to its
opposite — if that test has to be rewritten, that IS the review signal, and it should be rewritten
deliberately or not at all. The test that would catch a mistake in either direction already exists:
tmux.test.ts:290pins the refusal, andtmux.test.ts's "AN AGENT DECLARING NOTHING isbyte-identical" pins the case that must never start refusing.
Related: #438 — every fixture here (
tmux.test.ts,tool-registry.test.ts) answers the join bysql.includes("agent_instances"), so no test in the suite can tell an agent id from an instance id.This distinction is invisible to the suite by construction; it was found by reading the gate, not by
running it.
docs(constraints): the agent-template surface stays refused, and the …
serge-ivo commentedon Aug 12, 2026
The two residuals from the follow-up are in
156dfe2— the decision is Option A, and the doc says soShipped:
156dfe2(docs + one refusal message + one test). §1/§2/§3 were already live inefe8d5f/dbfad93/b6f6c9f, andcc84f51documented the posture; this closes what thefollow-up comment left.
The template surface stays REFUSED — Option A, decided on the code
Verified first, rather than inherited from the comment:
lookupConnectorConstraints(
lib/agent-capabilities.ts:588-611) joinsagent_instancesonly,resolveAgentCapabilities(
agent-think.ts:61-75) falls back to theagentsrow. The asymmetry is real.Option B is not taken, for three reasons in this order:
may fall back; the boundary may not — or a ceiling's applicability starts depending on which
KIND of id a caller happened to pass, which is the property
efe8d5fremoved. Invertingtool-registry.test.ts:334would be the review signal, and there is nothing to buy with it.getBoundRunnerConn(lib/runner-client.ts:74) resolvesinstance_runtimes/instance_runtime_nodesby instance id, written only behindrequireOwnedInstance. Thewrite half never reaches this gate at all:
instance_connector_consentis keyed by instance idtoo (
lib/connector-consent.ts:17-19) andsetConsent's only callers sit behind the same guard(
routes/tools.ts:438,481), so consent refuses every write tool two blocks earlier — which isexactly your measured table (5 writes byte-identical, 2 reads swapping one message for another).
The revisit condition is written into the doc rather than left in this thread: the first time a
ceiling-governed connector becomes cloud-backed, a trial chat is doing real work, the ceiling has
something to constrain, and the fallback stops being a widening.
Pinned as a property, not prose —
tool-registry.test.tsnow asserts the gate issues exactly onelookup and that it is the instance join. A second query fails the test, so taking Option B later is
a deliberate act with a review signal attached.
The message, which you called the weak part
It named the failure but not the surface, so "no runner yet" and "this surface can never have one"
read identically. Now:
Verified in production after the deploy (run 31558391260, green), on the real template surface:
POST /v1/agents/2dff5c62-…/chat→ "list my tmux sessions" → the tool ran and was refused with thenew wording. So the door #441 §1 described is closed and observable, not only unit-tested.
One honest note from that live check, which is NOT this ticket: the model then paraphrased the
refusal to the user as "go to the console, open Settings → Connections, and link a tmux instance"
— a console path that does not exist. The full result reaches the model (the 120-char cut at
agent-think.ts:1095is the displayed log only), so this is the #493 class — an agent inventing aremedy — not a truncation. Worth a BA look separately; I have not filed it.
The doc was stale where this issue wrote it
terminaland/terminal-target.tmuxhas carried a binding since[design] tmux is the one Operator connector with no capability ceiling — CONNECTOR_CONSTRAINTS has a single key, and #403 moved the published agent off it #447 (
sessions/boundSession, no value ceiling — the backend is tmux by construction), andGET/PUT /v1/instances/{id}/tmux-sessionis mounted (routes/instances-terminal.ts:56-59) andin
store/openapi.yaml:4671. Both are now in the doc, with the reason the mounts stay literaland the
instances.contract.test.tsguard that keeps them equal tobindRoute.tmuxhad no key, so none ofthe posture applied to
tmux_*at all — atmux_capture_panewith an unresolvable authority fellthrough to the handler and was refused for the absent runner. Adding the key turned the posture on
for all seven tools in one commit.
Still open — acceptance criterion 4
targets: "single"is declared (b6f6c9f,single-pane-operator, draft) but the four end-to-endoutcomes still need a machine with a tmux runner attached: bind → call with/without a target → call
another pane. That is the owner's, as your earlier comment said; I have not faked it with a mock.
Leaving this issue open for it.
feat(agents): the tmux coder is an agent with GitHub tools, not a par…
serge-ivo commentedon Aug 12, 2026
The refusal wording landed in
156dfe2behaves correctly and is the right answer — verified live today, and the remedy clause it added is accurate: the ceiling joinsagent_instancesonly (lib/agent-capabilities.ts:590,:598-602,:608), the template surface passes an agent id (agent-think.ts:1045/:1062), and the identical call fromPOST /v1/instances/:id/chatresolves because an instance DO'sstate.agentIdIS the instance id (routes/instances.ts:215). Nothing here argues for the fallback this issue's follow-up declined.What does not survive the trip is the message. Filed as #517: on 5/5 live calls the model discarded the remedy clause and told the owner to "go to Settings → Connections and connect a tmux instance" — an action no console screen offers — and the transcript's 120-char display cap (
agent-think.ts:1095) cuts the real remedy off screen entirely, landing at…names no subscribed instancefor tmux and mid-word at…no subscribed instafor terminal. So the owner reads a false remedy and cannot reach the true one. No change proposed to the gate.fix(agents): a refused tool's own remedy is what the agent relays, an…
fix(agents): a tool the template surface can only refuse is not offer…
serge-ivo commentedon Aug 18, 2026
Audit re-verification: three of four ACs delivered. Leaving open — but the blocker on AC4 has expired, so it is now doable.
Verified on
maintodaylib/tool-registry.ts:728-734:found.instance === "missing"returns a refusal naming the connector and the remedy, beside the throw-branch refusal at:719-725. Option A, as the156dfe2comment recordsagent_instancesjoin misses, which lands in the same"missing"branch. #517 step 3 (7629d24a) went further and stopped offering the tool on the template surface at allenforceConstraints({backends:["tmux","kitty"]}, {target:"tmux:main"})→ ok, narroweddbfad931targets: "single", four end-to-end outcomes recorded hereb6f6c9f6+ migration0112seedsingle-pane-operatorwith{"terminal":{"backends":["tmux"],"targets":"single"}}, and all four outcomes are asserted through the real parser, sanitiser, creator→subscriber merge and dispatcher gate. The live drive is still not recordedThe blocker on AC4 is gone
Your 2026-08-12 comment declined the live drive with the reason:
Measured today:
A tmux runner is attached and live. The four outcomes — unbound +
singlerefuses; bound passes and fills an omitted target; another pane is refused naming what is bound; another backend is refused as a BACKEND violation — can be observed end to end now.I have not run them: it means subscribing to
single-pane-operator,PUT /v1/instances/:id/terminal-target, and driving four dispatches on the owner's account, which is a mutation and outside a read-only audit. It needs the owner or the dev agent, and it is the only thing left on this ticket.serge-ivo commentedon Aug 23, 2026
Delivered-work audit 2026-08-23 — ACs 1–3 re-verified at
HEAD. AC4's live drive is the only thing left, and the runner is up.Independent re-verification against
main:workers/api/src/lib/tool-registry.ts:728-734returns a refusal whenfound.instance === "missing", beside the throw-branch refusal at:719-725("a boundary that opens when its store hiccups is not a boundary"). The posture is stated at:687-692: "FAIL-CLOSED IN BOTH ITS DIRECTIONS ([bug] The capability-constraint gate opens when there is no authority, refuses a target its own ceiling permits, and no agent declares targets:"single" #441)… A ceiling that cannot be READ and a ceiling that cannot be LOCATED are both refusals. Only 'the row is there and declares nothing' runs.":694-709records why the fix is in the gate and not at the caller: "a rule that holds only where the caller remembered is not a rule." The asymmetry withresolveAgentCapabilities(which does fall back to theagentsrow) is decided rather than inherited, with the revisit condition written down indocs/capability-constraints.md.lib/surface-options.ts:429-438(prefixOf) and:505-516; pinned atsurface-options.test.ts:334"multi-value ceiling:tmux:mainpasses, narrowed to the backend the target named" →{ target: "tmux:main", backend: "tmux" }, with:330confirming the single-value case is unchanged and:444-450keeping the rule that two different prefixes never match.migrations/0112_seed_single_pane_operator_agent.sql:93declares"targets": "single"alongside"backends": ["tmux"], and:15-43records whykitty-operator/iterm-operatorcorrectly did not get it.What remains is the live drive, and it needs the owner or a dev agent — subscribe to
single-pane-operator, bind a pane, and record the four outcomes here: unbound +singlerefuses; bound passes and fills an omitted target; another pane is refused naming what is bound; another backend is refused as a BACKEND violation. It is a mutation on a live account, which is why this audit did not do it.Verified: every
file:lineabove. Inferred: nothing.feat(catalog): the tmux Coder reads a repository with read-only tools…
serge-ivo commentedon Aug 29, 2026
All three acceptance criteria are met on main as of this review. Nothing to implement.
AC1 — no-authority dispatch is fail-closed: Fixed in
efe8d5fa.tool-registry.tslines 753–803 refuse any call without a resolvable instance authority, with the same wording as the unreadable-spec branch. Tests:tool-registry.test.tslines 366–419 (no resolvable authority → refused, three cases including the live-second-door agent-id path and the one-query pin).AC2 — multi-value ceiling narrows a prefixed target: Fixed in
dbfad931.surface-options.tslines 515–551 record a permitted prefix (namedByPrefix ||= prefix) and narrowbackendto it rather than falling through to the 'pass explicitly' branch. Tests:surface-options.test.tslines 327–355 (#441: two-of-three ceiling).AC3 — one agent declares
targets:"single"and four outcomes are recorded: Shipped inb6f6c9f6. Tests:operator-backend-ceiling.test.tslines 271–312 (outcomes a/a'/b/c/d all passing).