Skip to content

[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

Description

@serge-ivo

The one published Operator is the one that cannot be bound to a pane — CONNECTOR_CONSTRAINTS has no tmux entry

Filed from the #249 assessment, whose recommendation item 1 is "a tmux entry in
CONNECTOR_CONSTRAINTS with a targets binding"
and which says plainly: "Needs an issue — I have
not filed one."
#249 cannot close without it, because closing #249 "no tmux Coder backend" rests on
"use the Operator family if you want attach", and that argument is only honest once the Operator
path can be bound.

The gap, and how two correct decisions produced it

#403 (correct) moved the published tmux-operator off the generic terminal_* six and onto the
six backend-exclusive tmux_* tools, so it is a tmux agent by construction rather than by name.
Verified live — GET /v1/agents/my/agents:

"tmux-operator": { "surfaces": ["tmux"], "runtime": "coding", "workflow": null,
  "tools": ["tmux_list_sessions","tmux_capture_pane","tmux_run_command",
            "tmux_send_keys","tmux_new_session","tmux_kill_session"] }

#402/#404 (correct) put the capability ceiling in CONNECTOR_CONSTRAINTS, which has exactly
one key
, and says so (workers/api/src/lib/surface-options.ts:186-190):

"terminal is the first and, today, only entry."

Those two compose into a hole. The ceiling vocabulary is keyed by connector id
(parseConstraintSpec(id, raw)CONNECTOR_CONSTRAINTS[id], :236), and #403 moved the
published agent onto a connector that has no entry. So:

  • kitty-operator and iterm-operatordrafts, zero instances — carry
    {"terminal":{"backends":["kitty"]}} / ["iterm2"] and are gated at dispatch.
  • tmux-operatorpublished, with two live instances (tmux Operator, Heartfull (tmux)) —
    carries no surfaceOptions at all, and could not carry a useful one if someone wrote it: a
    tmux key is dropped by parseConstraintSpec because the vocabulary has no such connector.

Verified today across all 39 agents on the operator account: exactly three carry surfaceOptions,
and they are the three above.

What that leaves ungoverned. tmux_run_command is scope:"write" arbitrary shell on the user's
machine (connectors/tmux.ts:79-101), gated by one per-connector write-consent row that also
legitimises its five read-only siblings. A named operator — "Heartfull (tmux)" — is exactly the case
#402 wrote targets: "single" for: "One pane, one job. A mis-selected target here is a shell
command on a real machine."
It is the one shape that cannot be declared.

Why this is not #441

#441 §3 reports the same absence from the other end — "targets: "single" has never been exercised
end to end"
— and explicitly rejects the workaround:

"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."

So #441 names this blocker and routes around it (declare on a draft terminal agent instead). This
ticket is the blocker itself. They are complementary and neither subsumes the other: #441 fixes the
gate's behaviour and exercises the binding on the connector that already has a vocabulary; this makes
the published agent able to declare one.

What to do

  • Add a tmux entry to CONNECTOR_CONSTRAINTS (surface-options.ts:191), one binding
    field, no value field:

    ```ts
    tmux: {
        sessions: {
            kind: "binding",
            arg: "session",          // every tmux_* tool's schema names it `session`
            bindField: "boundSession",
            noun: "tmux session",
        },
    },
    ```
    
    No `values` field and no `withinField`: the backend is `tmux` by construction (that is what
    #403 bought), so there is nothing to narrow and nothing for the binding to sit inside. That
    also means `narrowConstraintSpec` and the dispatch gate in `runRegistryTool` are reused
    unchanged — this is one entry in a reviewed table, not new enforcement.
    
  • Check arg against the tools' own schemas. All six tmux_* tools take session
    (connectors/tmux.ts:60,88,114,142,…); tmux_list_sessions takes no arguments at all
    (jsonSchema: {type:"object", properties:{}}) and is therefore correctly ungated — a tool
    without the arg in its schema is not gated, by design. Add the same schema-vs-vocabulary
    assertion terminal.test.ts already makes, so the two cannot drift.

  • Generalise the binding write route, or add a sibling.
    routes/instances-terminal.ts:89-91 hardcodes CONNECTOR = "terminal", FIELD = "targets",
    BIND_FIELD = "boundTarget". The handler body is already connector-agnostic, so the cheapest
    correct shape is to parameterise those three and mount a second path — or to make the path
    itself carry the connector. This is the decision in the ticket, and I would parameterise
    rather than copy: two near-identical handlers writing surfaceOptions is how the two get to
    disagree about the read-merge-write at :75-80, which is the part that must not be duplicated.

  • Declare it on tmux-operator, as a migration in 0099's shape (json_set on
    $.capabilities.surfaceOptions.tmux, WHERE slug = 'tmux-operator'). Whether the seeded value
    is {"sessions":"single"} is the owner's call: it makes an unbound instance refuse rather
    than guess, which is the safe default but is a behaviour change for the two live instances.
    My recommendation: ship the vocabulary and the route first, leave the agent undeclared, and let
    the owner opt in per instance — the capability is what is missing, not the policy.

Alternatives considered and rejected

Acceptance criteria

  • parseConstraintSpec("tmux", {sessions: "single", boundSession: "main"}) returns both fields
    (today it returns undefined).
  • An instance of tmux-operator with sessions: "single" and no bound session refuses
    tmux_capture_pane with the "you must bind one" wording rather than guessing a pane.
  • With main bound: tmux_capture_pane {session:"main"} runs; {session:"other"} is refused.
  • tmux_list_sessions still runs unbound (no session in its schema).
  • A test asserts every arg in the tmux vocabulary appears in the corresponding tools'
    JSON-Schema properties, in the shape terminal.test.ts already uses.

Regression risk

  • The two live instances are the risk. tmux Operator and Heartfull (tmux) run today with no
    ceiling. Shipping the vocabulary changes nothing for them (an absent declaration is an absent
    ceiling); shipping a seeded sessions:"single" would refuse their next call until someone binds
    a session. That is why the migration is separated from the vocabulary above.
  • enforceConstraints returns its input byte-identically when nothing is declared
    (surface-options.ts:443-445, asserted in surface-options.test.ts). A new table key must not
    disturb that for any connector, tmux included.
  • If [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 §2 (a multi-value ceiling refusing a permitted prefixed target) lands first, re-check the
    prefix handling does not reach the tmux entry, which has no prefix args at all.

Files: workers/api/src/lib/surface-options.ts:154-212,236-260,440-505,
workers/api/src/lib/connectors/tmux.ts:29-160, workers/api/src/lib/tool-registry.ts:467-486,
workers/api/src/routes/instances-terminal.ts:32-95,
workers/api/src/lib/agent-capabilities.ts:561-584,
workers/api/migrations/0099_tmux_operator_backend_exclusive_tools.sql,
workers/api/migrations/0104_operator_backend_ceilings.sql, docs/capability-constraints.md.
Related: #249 (the assessment that asks for this, recommendation item 1), #402/#404 (the
ceiling and the binding), #403 (which moved the published agent onto the connector with no
vocabulary), #441 (the three other residuals, which explicitly names this blocker), #348
(why an Operator's drives are unmetered, and therefore why governing them matters).

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