Skip to content

Gate unsafe optional tmux features independently - #37

Merged
tony merged 12 commits into
masterfrom
bump-min-tmux-001
Oct 24, 2026
Merged

tony merged 12 commits into
masterfrom
bump-min-tmux-001

Conversation

@tony

@tony tony commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Keep ordinary operations, snapshots, the workspace builder and MCP startup on tmux 3.3a while refusing unsafe optional operations on the daemons that mishandle them, before their effects. This builds on the core minimum change and targets master.

Operation Requirement
Subprocess operations, snapshots, polling, workspace and MCP baseline 3.3a
Named buffer deletion and rename 3.4
Control connections (open_control, Async control, reconnect) 3.7
MCP tmux_wait screen_contains control events from 3.7; polls the screen with capture-pane before it, no refusal or opt-in; wrapped rows join, and a pane or server that ends first fails as pane_died
Raw break-pane, including lines sent over a control connection refused on exact 3.7; Pane#break_out renames after the move there
Session and window names containing : or ., typed or in raw new-session -s, new-window -n and rename-* refused on exact 3.7
Raw split-window -p refused on 3.4; typed splits always send -l N%
Raw refresh-client -l, including control lines refused below 3.4: on 3.3a, asking an attached xterm-256color or tmux-256color terminal for its clipboard stops the server
Malformed layouts refused before 3.4; 3.8 JSON layouts accepted from 3.8

Control acquisition is decided from the checked release before any control client attaches, because a fallback after the attach fails cannot protect it; a route no server checked probes the daemon with the subprocess probe first. Over control mode the probe's error line arrives outside any %begin/%end block, so the subprocess probe is the only version-check read. The Ruby port already holds a retained short private socket route, so the private alias requirement is met by the existing route. The MCP screen_contains wait polls the pane screen every 50 ms below 3.7 and attaches no control client. On both paths it joins wrapped rows, so text split across rows matches. After a stream end or a failed read the wait asks the daemon one hook-free run-shell -C question; a pane that is gone, or a daemon that reports it is gone (no server running, server exited unexpectedly, or a missing or refused socket), ends the wait. A probe that cannot run, such as a failed spawn, decides nothing, so a lost stream on a live pane stays observation_lost. A pane, or a tmux server, that ends before the text appears fails the wait with the new pane_died code, delivery: observed and the last rows read in error.capture; a control stream that ends while the pane lives, such as after a detach, returns observation_lost. Async ControlSubscription#next now ends the stream with StopIteration instead of returning nil. process_exit, captures and every other tool are unchanged. Tests that need control assert the refusal on older daemons instead of skipping, and examples demonstrate it.

Literal argv guards cover typed operations and raw Server#run, including ; batches, flag clusters and tmux's command abbreviations. ControlConnection#exchange and its Async twin apply the same rules to their command line, which they read as tmux does (quotes, backslashes, unquoted ;), because a nameless break-pane sent over a control connection killed an exact 3.7 daemon. Tmux command strings nested inside another command (the command given to if-shell or run-shell -C, brace blocks, $VARIABLE expansions) and command-alias contents are not read, so callers must not nest a command a gate refuses. Named paste-and-delete (paste-buffer -d -b) stays available on 3.3a because its lookup is exact. The library promises no captured run-shell stdout, so the 3.3 to 3.4 view-mode routing needs no refusal. Single-pane break-pane on 3.3a to 3.5 prints no ID, so Pane#break_out reads the window back from the pane.

Identity reads no longer use display-message, which fires after-display-message on every release: the snapshot's server identity and the Darwin freshness check read through the probe's error text with the fields joined by commas into one word (tmux reports only the first unparseable word, and a multi-line or spaced report is refused), and the break-out read-back uses list-panes.

Every release gate lives in one internal capability registry, emitted as the root capabilities.json manifest that the docs site reads for its capability pages; scripts/emit-capabilities --check fails when the manifest is stale. Window#select_layout raises UnsupportedFeatureError, not ArgumentError, for a JSON layout below 3.8-rc2.

Server#supports_control? reports whether control is available so callers and the examples can choose a polling path first; they no longer reach into private methods. The control gate follows the shared vectors: next-3.7 and 3.7-rc* rank below 3.7 and are refused, 3.8-rc2 and next-3.8 are admitted.

Not applicable here: split-window -p encoding (already -l N%), the layout header check (already present for every release), temporary-buffer cleanup callers (the library has none), and workspace colon or period names (the workspace normalizer already refuses them).

Validation, all against real daemons: 3.3a, 3.4, 3.5, 3.6b, 3.7, 3.7a, 3.7d, 3.8 and master (next-3.9). At the tip, the feature, wait, control, Async, server-control, MCP capture and application, snapshot and operations suites pass again on 3.3a, 3.4, 3.7, 3.7a and 3.8, and scripts/check outer passes.

  • The new feature test passes on each of those daemons with each refusal leaving a sentinel buffer, window, session or layout unchanged and the positive control running where the feature is safe: control refused with no client attached below 3.7 and working from 3.7; the MCP screen wait polling below 3.7 and attaching no client; buffers kept below 3.4; break-out naming on exact 3.7 and sole-pane read-back; names refused only on exact 3.7; raw -p refused only on 3.4; malformed layouts refused before 3.4 with the daemon alive. Unit tests cover separators, abbreviations, flag clusters and escaped semicolons both ways.
  • The control, Async, server-control, MCP wait and capture, capture-semantics, snapshot and operations suites pass on 3.3a, 3.6b and 3.7d. scripts/check mid passes after the rebase onto current master, including the manifest check.
  • Deliberate breaks, each failing the targeted tests and then restored: removing the control guard (the control, Async and wait tests fail on 3.3a); removing it from the Async scope only (3.6b); removing the buffer-deletion guard (3.3a); removing the raw break-pane refusal and the break-out name repair (3.7); removing the sole-pane read-back (3.3a); removing the 3.7 name check (3.7); removing the 3.4 -p guard (3.4); removing the layout guard (3.3a); restoring the refusal in the MCP screen wait (the wait tests error on 3.3a); and restoring display-message for the snapshot identity and the read-back (the hook test fails on 3.7d, 3.3a and 3.5); comparing the control gate by major and minor only (the vector test fails). Further breaks, each failing its test: removing the guard from exchange (both exchange tests fail on exact 3.7); making the raw name check a no-op (unit and 3.7 tests fail); dropping -J from the wait capture (the wrapped-row test fails on 3.3a and 3.7a); mapping pane_died back to stale_target (the dead-pane and killed-server tests fail on both); raising a tmux process exit as observation_lost again (killed-server test fails on 3.3a); and letting Kernel#loop swallow StopIteration in the Async subscription or the observer (detach test errors on 3.7a). Treating any failed probe as a dead daemon fails the failed-probe test on 3.3a and 3.7a and the lost-stream test on 3.7a. The killed-server case with a stopped second client, which keeps the server alive while it shuts down, is decided by a hook-free daemon probe, not a timer: 0 failures in 25 runs on each of 3.3a and 3.7a, against 25 of 25 failing with the earlier 0.25 s timer.

Not qualified: concurrent control acquisition beyond an idle attachment on 3.7 and newer is unverified. The Darwin freshness read is covered by the parser unit tests only because the host cannot run it. The full matrix and macOS are for hosted CI.

Closes #33.

@tony
tony force-pushed the bump-min-tmux-000 branch from f80ca37 to 92d22e2 Compare October 17, 2026 10:44
@tony
tony force-pushed the bump-min-tmux-001 branch from a9ccb73 to 29d712d Compare October 17, 2026 10:44
@tony
tony force-pushed the bump-min-tmux-000 branch from 92d22e2 to 9de038c Compare October 17, 2026 11:53
@tony
tony force-pushed the bump-min-tmux-001 branch 2 times, most recently from 8e5cdeb to 78ae2a3 Compare October 17, 2026 13:39
tony added 5 commits October 17, 2026 10:44
why: Short-lived control attaches crash 3.2a and 3.6b servers, and tmux
fixed the initialization faults in 3.7. A fallback after the attach
fails cannot protect acquisition, so the decision has to come first.
Subprocess operations, snapshots, polling and the workspace builder do
not need control and stay on the 3.3a floor.

what:
- Refuse control acquisition below 3.7 with UnsupportedFeatureError
  (delivery :not_sent, phase :admission) in ControlConnection, which
  covers Server#open_control and reconnect, and in the Async scope
- Let a server record its checked release on its socket route, so a
  control connection reads the release instead of probing again; a
  connection built on a route no server checked probes the daemon with
  the subprocess probe before it spawns the client
- Wait for MCP tmux_wait screen text by polling the pane screen every
  50 ms when the daemon takes no control client, with no control attach
  and no extra option; 3.7 and newer keep the control event path. The
  capability report and the tool description name both paths, and
  process_exit is unchanged
- Join wrapped rows in the wait's capture (capture-pane -J) on both
  paths, so text that the pane width split across rows still matches
- End a wait whose pane, or whose tmux server, ends first as the new
  pane_died result with delivery observed and the last rows read in
  error.capture, the same on the poll and the control path. It no
  longer comes back as stale_target, the bad-reference code. After a
  stream end or a failed read the wait asks the daemon one hook-free
  run-shell -C question, so a killed server that a stopped second client
  keeps alive still answers pane_died. Only a daemon that reports it is
  gone ends the wait: a probe that cannot run, such as a failed spawn,
  leaves a lost stream as observation_lost
- Add TmuxVersion.identity_argv and parse_identity for that question:
  the fields expand into one comma-joined word, because tmux reports
  only the first unparseable word, and a report that is multi-line,
  spaced, miscounted or successful is refused
- Report observation_lost only when the control stream ends while the
  pane and server live. Async ControlSubscription#next now ends the
  stream with StopIteration, which Kernel#loop used to swallow so next
  returned nil, and the observer reads it the same way
- Describe the new result in the tool description and its error schema
- Test each wait case on a poll lane and a control lane: a wrapped match,
  a pane that dies, a killed server (also with a stopped second client),
  a detached client on a live pane, a probe that cannot run and a
  hook-free probe, and unit-test the identity parser
- Make tests that need control assert the refusal on older daemons
  instead of skipping, because skipped tests mark a CI cell incomplete.
  Where control only witnessed output, poll the pane or observe a
  terminal client instead
- Let the examples demonstrate the refusal below 3.7, rewrite the
  capture example to poll the pane, and make the benchmark refuse early
  because its lanes compare against control transport
- Regenerate the public method reference, and the README capture
  excerpt that follows the polling example
- Expect the installed control examples to fail with the refusal below
  3.7 instead of attaching a client

Verified on real daemons 3.3a, 3.6b and 3.7d: acquisition is refused
with no client attached on the first two and works on the last; the
control, Async, server control, MCP wait/capture, snapshot and capture
semantics suites pass on all three. Removing the guards fails the
feature, control and Async tests (see the PR for the recorded breaks).
why: Some tmux releases mishandle commands the library or its callers
can send as literal argv. Deleting or renaming a named buffer that does
not exist acts on an unrelated buffer before 3.4. tmux 3.3a faults on a
malformed layout header. 3.4 breaks split-window -p. Exact 3.7 crashes
on break-pane without a name, ignores a given one, and 3.3a through 3.5
print no window ID when the broken pane is alone. Typed methods and raw
Server#run reach the same dispatch, so the guard belongs there.

what:
- Read the argv of every dispatch, including ';' batches and tmux's
  command abbreviations, and refuse before any effect: delete-buffer -b
  and set-buffer -b -n below 3.4; break-pane on exact 3.7; split-window
  -p on 3.4; malformed select-layout below 3.4
- Leave opaque programs and command-alias contents unread; the table of
  commands is a superset across releases, so an ambiguous abbreviation
  stays ambiguous
- Route typed operations through a private dispatch so that Async and
  blocking servers share the guard and Pane#break_out can mark its
  repaired call
- Make Pane#break_out reapply the name after the move on exact 3.7 and
  read the window back from the pane when break-pane prints no ID
- Keep named paste-and-delete available: its lookup is exact on 3.3a
- Ask the release whether it is exactly X.Y instead of comparing its
  key layout, and list native_guard.rb in the gemspec so installed gems
  load it
- Delete the byte test's buffer without a name below 3.4, where exact
  named deletion is refused

Verified on real daemons 3.3a, 3.4, 3.5, 3.6b, 3.7, 3.7d, 3.8 and master
(next-3.9): each refusal leaves a sentinel buffer, window or layout
untouched, and the positive control runs where the feature is safe.
Unit tests cover separators, abbreviations, flag clusters and escaped
semicolons in both directions.
why: Exact 3.7 rejects session and window names containing ':' or '.'
after the command has been accepted, which surfaced as a late command
failure. Earlier releases replace the characters and 3.7a lifts the
restriction, so only that one release needs the check.

what:
- Check names in the shared literal-name step, which new_session,
  new_window, rename and break_out already use, and refuse before
  dispatch on exact 3.7

Verified on exact 3.7: new_session and rename raise
UnsupportedFeatureError (delivery :not_sent) and the session list is
unchanged. 3.3a through 3.6b, 3.7d, 3.8 and master accept the same name.
why: Snapshot capture read the server's version, PID and start time with
display-message, and the Darwin process-identity check read the PID the
same way. display-message fires a user's after-display-message hook on
every release, so each snapshot ran that hook. The break-out read-back
had the same dependency.

what:
- Read the snapshot server identity and the Darwin freshness PID with
  the run-shell -C identity probe the wait already uses
- Read a sole pane's window back with list-panes instead
- Check the daemon's version with the subprocess probe before any
  control client attaches and carry the release on the route, because
  the probe's error line arrives outside any %begin/%end block over
  control mode

Verified: a test installs after-display-message and checks that a
snapshot and a break-out fire nothing while display-message itself still
fires it, on 3.3a, 3.5, 3.7d, 3.8 and master. The Darwin freshness path
is covered by the unit parser tests only; this host cannot run it.
why: The 3.3a floor does not apply to every feature. Readers need each
feature's own requirement, what an older daemon gets, and how far the
evidence reaches.

what:
- Add an Optional tmux features table to the behavior reference with
  the daemon requirement, the older-daemon outcome and a support state
  per feature, and say what the literal-argv guard does not read
- Note the 3.7 control requirement in the libtmux, Async and MCP guides
  and in the control and layout recipes
- Record the one unverified area: concurrent control acquisition beyond
  an idle attachment on 3.7 and newer
@tony
tony force-pushed the bump-min-tmux-001 branch from 78ae2a3 to 094d142 Compare October 17, 2026 15:46
@tony
tony force-pushed the bump-min-tmux-000 branch from f9b6bd4 to 25c396e Compare October 17, 2026 17:16
@tony
tony force-pushed the bump-min-tmux-001 branch from 094d142 to d17f2b2 Compare October 17, 2026 17:17
@tony
tony force-pushed the bump-min-tmux-000 branch 2 times, most recently from b2a63a6 to 355a492 Compare October 17, 2026 18:59
@tony
tony force-pushed the bump-min-tmux-001 branch from d17f2b2 to 546b180 Compare October 17, 2026 18:59
@tony
tony force-pushed the bump-min-tmux-000 branch from 355a492 to 0481a88 Compare October 18, 2026 02:26
@tony
tony force-pushed the bump-min-tmux-001 branch from 546b180 to e041fe6 Compare October 18, 2026 02:26
@tony
tony force-pushed the bump-min-tmux-000 branch from 0481a88 to 75af984 Compare October 18, 2026 04:51
tony added 2 commits October 17, 2026 23:52
why: The examples, the benchmark and the signature consumer decided
whether to use control by calling a private method and an internal
module. Callers need a public answer to choose a polling path first.

what:
- Add Server#supports_control?, true from tmux 3.7, with its RBS,
  contract mapping and behavior text
- Use it in the examples, the benchmark, the type consumer and the
  control test support instead of the private release
- Regenerate the method inventory

Verified with scripts/types --check and the unit tests.
why: next-3.7 and 3.7-rc builds rank below 3.7 and must not attach a
control client, while 3.8-rc2 and next-3.8 rank above it.

what:
- Add the accept and refuse vectors for the 3.7 control gate

Verified: comparing only major and minor makes the test fail.
@tony
tony force-pushed the bump-min-tmux-001 branch from e041fe6 to 173bf02 Compare October 18, 2026 04:54
@tony
tony deployed to docs-preview October 18, 2026 06:10 — with GitHub Actions Active
@tony
tony force-pushed the bump-min-tmux-000 branch from 75af984 to 4fec2ca Compare October 18, 2026 07:19
@tony
tony force-pushed the bump-min-tmux-001 branch from 173bf02 to 8ad3c7e Compare October 18, 2026 07:19
@tony
tony force-pushed the bump-min-tmux-000 branch from 4fec2ca to 1463e64 Compare October 18, 2026 07:21
@tony
tony force-pushed the bump-min-tmux-001 branch from 8ad3c7e to 2a054d7 Compare October 18, 2026 07:21
@tony
tony deployed to docs-preview October 18, 2026 09:07 — with GitHub Actions Active
why: Each release gate carried its own copy of a floor or an affected
release, so the docs could only repeat them by hand. One table that the
gates read also feeds the capability pages the docs site builds, and a
drift test keeps the two equal.

what:
- Add Internal::Capabilities, a typed table of capability and
  workaround records beside Internal::TmuxVersion
- Read the control, named-buffer, layout, split, break-pane and name
  gates from the table instead of literals
- Raise UnsupportedFeatureError for a JSON layout below tmux 3.8-rc2,
  so the refusal matches the error the manifest names
- Add scripts/emit-capabilities, which writes the root
  capabilities.json and checks it with --check from scripts/check
- Add tests: the committed manifest equals the registry, every API name
  is a public symbol, and availability follows the release order
- List capabilities.rb in libtmux.gemspec
@tony
tony force-pushed the bump-min-tmux-001 branch from 2a054d7 to 957d12e Compare October 20, 2026 04:12
@tony
tony changed the base branch from bump-min-tmux-000 to master October 20, 2026 04:12
@tony
tony deployed to docs-preview October 20, 2026 04:16 — with GitHub Actions Active
@tony
tony force-pushed the bump-min-tmux-001 branch from 957d12e to 3d9ccd7 Compare October 20, 2026 08:36
@tony
tony deployed to docs-preview October 20, 2026 08:41 — with GitHub Actions Active
tony added 2 commits October 20, 2026 04:05
why: ControlConnection#exchange sent any command line to the daemon.
Exact tmux 3.7 is the one release where control opens and a nameless
break-pane kills the server, so exchange("break-pane") took the daemon
down while the same command through Server#run was refused.

what:
- Read each exchange line the way tmux parses a command string (quotes,
  backslashes, unquoted ";" separators, brace blocks) and run it through
  the same literal-command rules as Server#run, so aliases, unique
  prefixes and ";" lists resolve as tmux resolves them. The Async
  facade shares the check
- Keep the line parsing beside the argv rules in NativeGuard
- Name the control path among the break-pane gate's APIs, regenerate the
  manifest, and describe the line reading in the behavior reference
- Test unit vectors for refusals and for text that only mentions a
  command, and real-daemon tests that send break-pane over the sync and
  Async connections on exact 3.7, expect UnsupportedFeatureError with
  delivery :not_sent, and check the daemon answers afterwards
- Regenerate docs/reference/api.md, whose source links move with the
  edited files

Verified on 3.7 (refused, daemon survives), 3.7a (runs) and 3.3a. Removing
the check fails both exchange tests on 3.7.
why: Typed calls refuse ':' and '.' in session and window names on exact
tmux 3.7, but raw new-session -s, new-window -n and rename-* reached
tmux, which answered with its own error. A raw caller got a failed
CommandResult where a typed caller got UnsupportedFeatureError. The
registry summary also said tmux partly runs the command, which a probe
of every spelling did not show.

what:
- Give new-session, new-window, rename-session and rename-window option
  specs in NativeGuard and refuse a name with ':' or '.' on exact 3.7,
  through abbreviations and ";" lists, with the typed call's error
- Reword the registry summary, add Server#run to the APIs, regenerate
  the manifest, and describe the raw forms in the behavior table
- Test the raw forms on the unit vectors and on a real 3.7 daemon with
  no session or window created
- Regenerate docs/reference/api.md, whose source links move with the
  edited files

Verified on 3.7, 3.4 and 3.3a. Making the name check a no-op fails the
unit test and the 3.7 integration test.
@tony
tony force-pushed the bump-min-tmux-001 branch from 3d9ccd7 to e1076f9 Compare October 20, 2026 09:10
@tony
tony deployed to docs-preview October 20, 2026 09:15 — with GitHub Actions Active
@tony
tony force-pushed the bump-min-tmux-001 branch from e1076f9 to 971a630 Compare October 20, 2026 09:18
@tony
tony deployed to docs-preview October 20, 2026 09:22 — with GitHub Actions Active
@tony
tony force-pushed the bump-min-tmux-001 branch from 971a630 to 8bb8dda Compare October 20, 2026 22:01
@tony
tony deployed to docs-preview October 20, 2026 23:46 — with GitHub Actions Active
tony added 2 commits October 23, 2026 20:12
why: tmux 3.3a stops when refresh-client -l asks an attached
xterm-256color or tmux-256color terminal for its clipboard, and on 3.4
through 3.6 the command returns but the server stops when that client
leaves, killed or detached, before its terminal answers; 3.6a and later
survive. The native guard refused the other commands that crash a
supported release, but not this one, so one raw command or control line
could take down a supported daemon.

what:
- Refuse refresh-client, under any name tmux resolves to it, carrying
  -l below 3.6a in Server#run and ControlConnection#exchange lines,
  before anything is sent
- Record the gate in the registry as clipboard-query and regenerate
  capabilities.json
- Add unit tests for the spellings, flag positions and a control line
  on 3.3a through 3.6 and from 3.6a, and an integration test whose list
  of affected releases is written out, so moving the floor fails it
- Add the row to the optional features table
why: Upgraders on tmux 3.3a through 3.6 lose control connections and
need to know the alternative. The other gates refuse commands that
misbehave on one release.

what:
- Record the 3.7 control requirement as a breaking change, with the
  support check and the polling alternative
- Record the release-specific command refusals and the break-out repair
- Record the hook-free snapshot identity read
- Record the polling screen wait in libtmux-mcp, and the wait contracts:
  wrapped rows, the pane_died result and the lost-stream result
- Record the control-line refusals, the raw name refusals and the Async
  subscription end-of-stream fix
- Record the capability registry and the capabilities.json manifest
  for the docs capability pages
@tony
tony force-pushed the bump-min-tmux-001 branch from 8bb8dda to e9bcac8 Compare October 24, 2026 01:13
@tony
tony deployed to docs-preview October 24, 2026 01:19 — with GitHub Actions Active
@tony
tony force-pushed the bump-min-tmux-001 branch from e9bcac8 to bff3b1f Compare October 24, 2026 08:14
@tony
tony deployed to docs-preview October 24, 2026 08:21 — with GitHub Actions Active
@tony
tony marked this pull request as ready for review October 24, 2026 08:36
@tony
tony merged commit a2fa58e into master Oct 24, 2026
28 checks passed
@tony
tony deployed to docs-preview-cleanup October 24, 2026 08:36 — with GitHub Actions Active

This branch was successfully deployed

2 active deployments
docs-preview-cleanup — bff3b1f9 Deployed Oct 24, 2026 by tony via cleanup #39
docs-preview — bff3b1f9 Deployed Oct 24, 2026 by tony via publish (pr-37, pr, false, docs-preview, true) / publish #241
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gate unsafe optional tmux features independently

1 participant