Skip to content

[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

Closed

Description

@serge-ivo

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 an if.
  • Canonicalise a prefixed target into backend at the route/tool boundary instead of in the
    gate.
    Rejected: instances-terminal.ts deliberately 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 on tmux-operator. Rejected for 0104's own reason:
    since 0099 it declares tmux_* tools whose connector is tmux, 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
    backend narrowed to tmux; 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 every runRegistryTool call site for a ctx without instanceId before flipping it —
    today they all have one, and that must be re-checked, not assumed.
  • enforceConstraints returns the input byte-identically when nothing is declared
    (surface-options.ts:443-445, asserted in surface-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

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in efe8d5f (fail-closed gate), dbfad93 (prefix narrowing) and b6f6c9f (the single declaration + migration 0112), all on main. CI green, Deploy API Worker green, d1 migrations apply pags --remote reports 0112_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 + enforceConstraints from main — all four measured cases came back exactly as the issue states, including the refusal that names all when the caller never used a wildcard.

    1 — the gate now fails closed in both its directions

    The distinction that made this fixable is that undefined had 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. connectorConstraintsForInstance survives as a thin wrapper for routes/instances-terminal.ts, which has already established the instance.

    The second door is closed at the gate, not at the caller. agent-think.ts was 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:

    Site Authority Verdict
    agent-think.ts:941, :958 instanceId: state.agentId non-empty, but an agent id on /v1/agents/:id/chat + the public trial → join misses → now refused (was silently unconstrained)
    lib/pipeline.ts:514,523 PipelineRunCtx.instanceId, a required field unaffected
    lib/steps.ts:669,718,802 forwards the dispatching ctx unaffected
    routes/tools.ts:160,650 route instanceId after requireOwnedInstance unaffected

    Blast radius stated honestly: only a connector present in CONNECTOR_CONSTRAINTS reaches this gate, and today that is terminal alone. 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's envConsent answered null to 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. No CONNECTOR_CONSTRAINTS table entry was touched — that is #447's, and it is live.

    2 — a permitted prefix is now recorded, not discarded

    enforceConstraints records a permitted prefix and narrows def.arg to 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 and matchesBoundTarget's two-different-prefixes rule are untouched.

    3 — targets: "single" is declared, and here are the four outcomes

    Migration 0112 seeds a draft single-pane-operator carrying {"terminal": {"backends": ["tmux"], "targets": "single"}}, tools terminal_list_targets/capture/run_command/send_keys — deliberately not new_target/kill_target, since #402 is explicit that "may not create a terminal" is expressed by not declaring the tool. No boundTarget: 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:

    Outcome Result
    Both keys survive a round-trip through sanitizeDeclaredCapabilities ✅ — the property this issue says had never been exercised
    (a) unbound + single → refused, not guessed ✅
    (b) bound → passes; an omitted target is filled; bare main canonicalises to tmux:main ✅
    (c) a different pane → refused, naming what is bound ✅
    (d) a target naming another backend → refused as a BACKEND violation, not "not the bound target" ✅
    A subscriber cannot bind outside the creator's ceiling ✅

    Confirmed 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-target against a real subscribed instance with pags up running, 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:

  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    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.md now states the fail-closed posture

    Your 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-506 and lookupConnectorConstraints rather than transcribed from this thread:

    Docs only. No behaviour change.

    Not done — criterion 4's live drive

    PUT /v1/instances/:id/terminal-target against a real subscribed instance with the refusals arriving in a real chat turn. Checked before attempting:

    GET /v1/terminals/nodes  →  node "Mac", runnerVersion 0.4.44,
                                lastSeenAt 2026-08-08 20:57Z,  connected: false
                                (every instance on it: connected false)
    

    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 — per pags/CLAUDE.md an agent does not run the runner itself, and pags up is the owner's machine.

    Two things I did confirm live, since they were cheap and are claims in this thread:

    • single-pane-operator exists on the account carrying {"terminal": {"backends": ["tmux"], "targets": "single"}}, as migration 0112 seeds it;
    • it is not in the public catalog — GET /v1/agents returns 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.

  3. serge-ivo commented on Aug 11, 2026

    @serge-ivo
    ContributorAuthor

    #447 turned this posture on for a SECOND connector, and the ticket predicted the opposite — measured: the capability did not change, the message did

    4fda69d added a tmux key to CONNECTOR_CONSTRAINTS. #447's regression section said shipping the
    vocabulary 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 main and against production, because "live regression" and "latent
    inconsistency" want different answers.

    1. The lookup really is skipped for a connector absent from the table

    workers/api/src/lib/tool-registry.ts:548:

    if (connector && CONNECTOR_CONSTRAINTS[connector]) {

    Every part of the posture sits inside that if: the throw-refusal (:552-562), the missing-row
    refusal (:564-570), enforceConstraints (:571). So while tmux had no key, none of #441
    applied to tmux_* at all. The table now has two keys — terminal (surface-options.ts:214) and
    tmux (:235-250) — so it does.

    2. Seven tools, not the six #447 described

    migrations/0117_tmux_operator_send_message_tool.sql:27-33 declares seven on the published
    tmux-operator; #482 added tmux_send_message after #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 BEFORE enforceConstraints
    (:571), so "tmux_list_sessions stays ungated" holds only for the LOCATED case. Under an
    unresolvable 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.agentId handed to an instance-shaped parameter

    agent-think.ts:1007 and :1024 (they were :945,:962 when you filed this) pass
    instanceId: state.agentId. An instance DO is initialised with agentId: instanceId
    (routes/instances.ts:215), so that field is an instance id there — but the template DO is keyed by
    the agent row (routes/chat.ts:37,48), and the public trial DO copies template state under
    trial:{agentId}:{sid} (routes/public.ts:109-124). lookupConnectorConstraints joins
    agent_instances only (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 the agents
    row, 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 runRegistryTool with instanceId = an agent id (a D1 that answers nothing to the
    join) and the runner mocked absent. "Before" = the same tree with the tmux key deleted from
    CONNECTOR_CONSTRAINTS, which is the pre-4fda69d table exactly — that key is the only difference.

    tool before after
    tmux_list_sessions refused — "No runner is connected for this agent — run pags up …" refused — "…declared constraints for the tmux connector could not be resolved…"
    tmux_capture_pane refused — "No runner is connected…" refused — "…could not be resolved…"
    tmux_run_command refused — "Writing via the tmux connector isn't permitted…" unchanged
    tmux_send_keys "Writing via the tmux connector isn't permitted…" unchanged
    tmux_send_message "Writing via the tmux connector isn't permitted…" unchanged
    tmux_new_session "Writing via the tmux connector isn't permitted…" unchanged
    tmux_kill_session "Writing via the tmux connector isn't permitted…" unchanged

    The five writes are unchanged because the write-consent gate answers first
    (tool-registry.ts:496-507) and no instance_connector_consent row can be keyed by an agent id —
    setConsent's only caller is behind requireOwnedInstance (routes/tools.ts:438). Two reads
    swapped 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) resolves
    instance_runtimes / instance_runtime_nodes by instance id, and the only writer of those rows
    is POST /v1/instances/:instanceId/runtime behind requireOwnedInstance
    (routes/instances.ts:330-333, routes/instances-runtime.ts:671-682), which reads
    agent_instances. An agent id can never appear there.

    Measured live (GET /v1/terminals/nodes, read-only): 37 registered runtime ids across the
    account, none equal to the tmux-operator agent id 2dff5c62-59f0-4d2d-b0a6-b1db5c879c46; both
    its instances — 1a8486b5-… "tmux Operator" and cda75e28-… "Heartfull (tmux)" — are registered and
    connected. Neither declares surfaceOptions (GET /v1/agents/my/agents), so §5-of-#447's promise to
    them 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

    • Decide the template surface and write it down. Option A (my recommendation): leave it
      refusing, and add one line to docs/capability-constraints.md saying so — a dispatch that
      names no instance has no subscriber half to narrow, and "refused" is the honest answer.
      Option B: give lookupConnectorConstraints the second query resolveAgentCapabilities
      already has (fall back to the agents row → the CREATOR's ceiling, requested empty), which
      would 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-operator reads "declared constraints … could not be
      resolved"
      about an agent that declares none, when the true answer is "no runner, and this
      surface can never have one".
    • docs/capability-constraints.md is stale in the section this issue wrote. The Shape block
      (:99-103) still shows only terminal and PUT /v1/instances/{id}/terminal-target, while
      /v1/instances/{id}/tmux-session is mounted (routes/instances-terminal.ts:58-59) and is in
      store/openapi.yaml. :134-137 warns that "adding a key to that table therefore turns the
      posture 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 tmux as the worked example (with the
      table 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,523 via registryCtx at :490 (the run's instanceId), steps.ts:669,718,802
    (forwards the dispatching ctx), routes/tools.ts:160,679 (after requireOwnedInstance) — only
    agent-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:290 pins the refusal, and tmux.test.ts's "AN AGENT DECLARING NOTHING is
    byte-identical" pins the case that must never start refusing.

    Related: #438 — every fixture here (tmux.test.ts, tool-registry.test.ts) answers the join by
    sql.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.

  4. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    The two residuals from the follow-up are in 156dfe2 — the decision is Option A, and the doc says so

    Shipped: 156dfe2 (docs + one refusal message + one test). §1/§2/§3 were already live in
    efe8d5f / dbfad93 / b6f6c9f, and cc84f51 documented the posture; this closes what the
    follow-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) joins agent_instances only, resolveAgentCapabilities
    (agent-think.ts:61-75) falls back to the agents row. The asymmetry is real.

    Option B is not taken, for three reasons in this order:

    1. The asymmetry runs the safe way round. The permissive resolver (which tools exist at all)
      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 efe8d5f removed. Inverting
      tool-registry.test.ts:334 would be the review signal, and there is nothing to buy with it.
    2. It would govern nothing. Both connectors with a vocabulary are runner-backed, and
      getBoundRunnerConn (lib/runner-client.ts:74) resolves instance_runtimes /
      instance_runtime_nodes by instance id, written only behind requireOwnedInstance. The
      write half never reaches this gate at all: instance_connector_consent is keyed by instance id
      too (lib/connector-consent.ts:17-19) and setConsent's only callers sit behind the same guard
      (routes/tools.ts:438,481), so consent refuses every write tool two blocks earlier — which is
      exactly your measured table (5 writes byte-identical, 2 reads swapping one message for another).
    3. Half the ceiling does not exist for a template — no binding, no subscriber.

    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.ts now asserts the gate issues exactly one
    lookup 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:

    This agent's declared constraints for the tmux connector could not be resolved — this call names
    no subscribed instance for them to belong to — so "tmux_list_sessions" was refused rather than
    run unconstrained. A ceiling is resolved per instance (the creator's declaration, narrowed by that
    instance's own binding), so run this from a subscribed instance rather than from a template
    preview or trial chat.

    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 the
    new 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:1095 is the displayed log only), so this is the #493 class — an agent inventing a
    remedy — not a truncation. Worth a BA look separately; I have not filed it.

    The doc was stale where this issue wrote it

    Still open — acceptance criterion 4

    targets: "single" is declared (b6f6c9f, single-pane-operator, draft) but the four end-to-end
    outcomes 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.

  5. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    The refusal wording landed in 156dfe2 behaves correctly and is the right answer — verified live today, and the remedy clause it added is accurate: the ceiling joins agent_instances only (lib/agent-capabilities.ts:590, :598-602, :608), the template surface passes an agent id (agent-think.ts:1045/:1062), and the identical call from POST /v1/instances/:id/chat resolves because an instance DO's state.agentId IS 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 instance for tmux and mid-word at …no subscribed insta for terminal. So the owner reads a false remedy and cannot reach the true one. No change proposed to the gate.

  6. serge-ivo commented on Aug 18, 2026

    @serge-ivo
    ContributorAuthor

    Audit re-verification: three of four ACs delivered. Leaving open — but the blocker on AC4 has expired, so it is now doable.

    Verified on main today

    AC evidence
    1. no resolvable authority has one defined, tested behaviour matching the neighbouring gates Met, fail-closed — lib/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 the 156dfe2 comment records
    2. an authority that is an agent id behaves the same way Met — the agent_instances join misses, which lands in the same "missing" branch. #517 step 3 (7629d24a) went further and stopped offering the tool on the template surface at all
    3. enforceConstraints({backends:["tmux","kitty"]}, {target:"tmux:main"}) → ok, narrowed Met — dbfad931
    4. one agent declares targets: "single", four end-to-end outcomes recorded here Half met — b6f6c9f6 + migration 0112 seed single-pane-operator with {"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 recorded

    The blocker on AC4 is gone

    Your 2026-08-12 comment declined the live drive with the reason:

    GET /v1/terminals/nodes → node "Mac", runnerVersion 0.4.44, lastSeenAt 2026-08-08 20:57Z, connected: false … No relay socket is up, so a terminal_* dispatch has nothing to reach.

    Measured today:

    RLs-MacBook-Air.local   runnerVersion 0.4.54   connected: true
      tmux Operator      tmux-operator   connected: true   runtime: coding
      Heartfull (tmux)   tmux-operator   connected: true   runtime: coding
      AIPA tmux Coder    tmux-coder      connected: true   runtime: coding
    

    A tmux runner is attached and live. The four outcomes — unbound + single refuses; 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.

  7. added
    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guards
    bugSomething isn't working
    securitySecurity hardening / audit finding
    backendBackend / Worker / API work
    on Aug 18, 2026
  8. serge-ivo commented on Aug 23, 2026

    @serge-ivo
    ContributorAuthor

    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:

    • AC1, fail-closed on no authority — workers/api/src/lib/tool-registry.ts:728-734 returns a refusal when found.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."
    • AC2, an agent id behaves the same way — same branch, and the reasoning at :694-709 records 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 with resolveAgentCapabilities (which does fall back to the agents row) is decided rather than inherited, with the revisit condition written down in docs/capability-constraints.md.
    • AC3, a prefixed target narrows — lib/surface-options.ts:429-438 (prefixOf) and :505-516; pinned at surface-options.test.ts:334 "multi-value ceiling: tmux:main passes, narrowed to the backend the target named" → { target: "tmux:main", backend: "tmux" }, with :330 confirming the single-value case is unchanged and :444-450 keeping the rule that two different prefixes never match.
    • AC4, half met — migrations/0112_seed_single_pane_operator_agent.sql:93 declares "targets": "single" alongside "backends": ["tmux"], and :15-43 records why kitty-operator/iterm-operator correctly 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 + single refuses; 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:line above. Inferred: nothing.

  9. serge-ivo commented on Aug 29, 2026

    @serge-ivo
    ContributorAuthor

    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.ts lines 753–803 refuse any call without a resolvable instance authority, with the same wording as the unreadable-spec branch. Tests: tool-registry.test.ts lines 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.ts lines 515–551 record a permitted prefix (namedByPrefix ||= prefix) and narrow backend to it rather than falling through to the 'pass explicitly' branch. Tests: surface-options.test.ts lines 327–355 (#441: two-of-three ceiling).

    AC3 — one agent declares targets:"single" and four outcomes are recorded: Shipped in b6f6c9f6. Tests: operator-backend-ceiling.test.ts lines 271–312 (outcomes a/a'/b/c/d all passing).

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

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingsecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions