Skip to content

Gate unsafe optional tmux features independently - #72

Draft
tony wants to merge 13 commits into
bump-min-tmux-000from
bump-min-tmux-001
Draft

tony wants to merge 13 commits into
bump-min-tmux-000from
bump-min-tmux-001

Conversation

@tony

@tony tony commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Keep ordinary operations, workspace builds and MCP startup on tmux 3.3a while refusing unsafe optional operations before their effects. This is stacked on the core minimum change.

Operation Required tmux
Ordinary subprocess operations, polling, workspace and MCP baseline 3.3a
Named buffer deletion/rename and managed paste, whose cleanup needs them 3.4
Captured run-shell output (existing refusal, unchanged) 3.5
Managed control: Server.control, ControlClient.attach, watches, mirrors, streams, live views 3.7

Exact 3.7 also refuses raw break-pane and unnamed Pane.breakOut(); breakOut(name) keeps its post-break rename. Literal split-window -p is refused on 3.4 (typed percent keeps -l N%). Names with . or : are refused on exactly 3.7 before dispatch. Literal select-layout below 3.4 must name a layout or carry tmux's checksum, because 3.3a ends the server on anything else. A single-pane break that prints no id before 3.6 is read back from the moved pane. Direct paste-buffer -d -b stays available on 3.3a.

Control acquisition decides on the daemon before any control client exists, with the hook-free probe and the release-train order next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya: next-3.7 and 3.7-rc2 do not qualify, 3.8-rc2 and next-3.8 do. It then reads the identity by subprocess (run-shell -C, one word: version, pid and start time joined by commas, the socket path last in single quotes with its quotes reopened), pins the socket through a hard link in a private 0700 directory on the socket's filesystem (alias under 103 bytes, removed on close, EOF or failed attach), rereads the identity through the pin, attaches with -N to the alias, and rereads it again by subprocess after the attach. Nothing is read over the control line: run-shell -C reports through stderr, which a control reply does not carry. A custom ControlCarrier must implement pinSocket; the default refuses.

Subprocess and polling paths are untouched below 3.7. The guards live in one place, TmuxFeatures, applied after the version check to the literal commands a request carries (full verbs, aliases and unambiguous prefixes, flag clustering, --), so typed calls, raw commands, flat batches and handle batches all pass through it and a refused batch runs none of its commands. Opaque programs (if-shell bodies, source-file, shell text, alias contents) keep native semantics, and no second script parser is added.

The workspace CLI requires TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2 of the Python libtmux next to its 3.3a floor, in the probe and again in the child, before external code runs. CI qualifies tmuxp 1.74.0 with the Python libtmux source of the Python libtmux feature change at 54b277bb673e208820fc9c795abe4444e2ded272; that overlay is not a released package pair.

Validation, on this Linux/WSL host:

  • The whole real-tmux integration lane passes on 3.3a, 3.5, 3.7 and master (next-3.9, which now runs the control cases); control-positive cases carry the control tag and run on every daemon that qualifies, and every lane asserts the refusal and that no client attached. The unit suites of :libtmux, :libtmux-junit5, :libtmux-kotlin, :libtmux-scala-cats and :libtmux-workspace, and the documentation checks, pass; the workspace CLI ExecutionTest passes with the source overlay.
  • Deliberate breaks: removing TmuxFeatures.require from the version check fails three OptionalFeatureIntegrationTest cases on 3.3a (named buffers, managed paste, layouts). Making the control floor a no-op fails ControlFloorIntegrationTest on 3.5 ("Expected UnsupportedFeatureException to be thrown, but nothing was thrown"). A table-driven ControlFloorVectorsTest holds the floor to the canonical version vectors; making the floor refuse development and candidate builds again fails it ("3.8-rc ==> expected: true"). Reading the daemon with display-message again in the acquisition recheck fails the hook case on 3.7 ("acquisition ran the hook").
  • ControlAcquisitionIntegrationTest also covers a socket path with a space, a quote and a comma, a long socket path through the short alias, replacement of the original pathname after pinning, and the hook staying quiet through qualification and recheck while a control display-message fires it.

Exact 3.7 rejects . and : in session and window names; Names.require refuses them before dispatch for new-session, new-window, rename-session and rename-window (and for workspace window names before staging), with a real-lane case and a recorded break. Not done or not qualified: On a local tmux 3.8 build ServerMirrorIntegrationTest.aRebuildThatFindsNothingNewPublishesNothing fails (a control-mirror case that the preview lane now reaches); it was not diagnosed. Tmux 3.4, 3.6 and the 3.7 patch releases were not run locally and are CI qualification, as is macOS. Once a client is attached, control replies are framed with display-message markers; that is the client's own traffic and still fires a user's after-display-message hook. Reads that print a version with display-message inside a checked call are unchanged. Linux evidence does not establish pinning on macOS. Clojure draft PR20 is a separate follow-up.

Closes #68.

tony added 8 commits October 17, 2026 04:27
why: Control clients now need tmux 3.7, but several integration cases
only needed some client attached to a session, and the workspace CLI
process test kept a private copy of the PTY helper. Both need the same
standard-library pseudo-terminal driver.

what:
- Move the PTY helper into gradle/test-fixtures and serve it as a test
  resource of integration-tests and libtmux-workspace-cli, so it never
  enters a published artifact.
- Add NativeClient, an ordinary terminal client that attaches to a
  session through that driver, owns its process tree and detaches on
  close.
- ProcessTest reads the shared helper.

Python 3 is already required by the PTY tests; CONTRIBUTING is updated
with the control commit's documentation.
why: The workspace CLI hands builders, plugins and shell code to a
Python libtmux. The core version check (3.3a) alone does not tell the CLI that
interpreter also refuses the optional operations Java refuses, so a
backend with only the floor could run an unsafe literal command.

what:
- The runtime prelude requires the integer
  TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2 next to the 3.3a floor, in
  the runtime probe and again in the child, before external code runs.
- Cases cover a missing, 0, 1, boolean, string and float marker, and a
  child that passes the probe but fails the recheck; none enters the
  backend.
- CI and the tmux matrix qualify tmuxp 1.74.0 with the Python libtmux
  source overlay that carries the marker (tmux-python/libtmux PR 780),
  and assert the marker besides the import path and floor.

Released tmuxp still pins libtmux 0.61, so the overlay is not a
coherent installed package pair.
why: At the 3.3a floor a few operations still misbehave on particular
releases: named buffer delete and rename act on an unrelated buffer
before 3.4, `split-window -p` is unreliable on 3.4, an invalid layout
string kills a 3.3a daemon, an unnamed or raw `break-pane` ends or
renames on exact 3.7, and a single-pane break prints no id before 3.6.
Each must be decided from the connected daemon, for typed calls and for
literal commands alike, so it can no longer fail after an earlier
command in a batch had its effect.

what:
- TmuxFeatures inspects the literal commands a request carries (full
  verbs, aliases and unambiguous prefixes, option clustering, `--`)
  against the checked daemon version and refuses with
  UnsupportedFeatureException or IllegalArgumentException before the
  fenced batch is sent.
- Versions.execute applies it after the version check; Server, IncarnationFence
  and handle batches pass the commands they represent.
- Buffers.delete, Pane.paste and its callback form stop checking the
  version themselves: the dispatch-level guard covers them, and the
  paste temporary buffer is refused before it is created.
- Pane.breakOut() is refused on exact 3.7; breakOut(name) keeps the
  post-break rename. A break is read back with display-message on the
  moved pane, so releases that print nothing for a single-pane break
  still return the new window.
- Direct `paste-buffer -d -b`, user-authored if-shell bodies and alias
  contents keep native behaviour; no second script parser is added.

Verified on real tmux with OptionalFeatureIntegrationTest.
why: Control clients are only qualified on stable tmux 3.7 and newer;
a short-lived control attach crashes 3.2a and 3.6b servers. Until now
the version check ran through the carrier and the later `-C` attach
named the socket path again, so a daemon replaced in between could
receive the attach, and a long socket path could exceed sun_path.

what:
- ControlAcquisition decides first, with the hook-free version probe,
  and refuses a daemon below a stable 3.7 (development builds and
  release candidates included) before any control client exists. It
  then reads the daemon identity, pins the socket, rereads the identity
  through the pin and attaches with `-N` to the pinned alias. One
  deadline covers qualification and attach.
- ControlCarrier gains pinSocket; the default refuses, so custom
  carriers must retain the socket in their own realm. The process
  transport and the local carrier use LocalSocketPin: a private
  0700 directory on the socket's filesystem holding a hard link, under
  the 103-byte limit, removed on close, EOF or failed attach.
- ControlClient.attach, attachUnfenced and Server.control go through
  it; Server.control refuses before its identity read. Literal commands
  sent over a control line pass the same feature guards.
- Subprocess and polling paths are untouched below 3.7.
- Tests: control-positive cases carry the control tag and the matrix
  runs them from 3.7; every lane asserts the refusal and that no client
  attached. Cases that only needed some attached client use NativeClient.
  Fakes (FakeTmux, the Kotlin and Cats control scripts, ControlClientTest)
  answer the probe, the identity read and the pin.

Verified on real tmux 3.3a and 3.7 with ControlAcquisitionIntegrationTest,
ControlFloorIntegrationTest and the control suites.
why: The guides still described one support range and a control client
that attaches on any supported daemon, and the documentation checks
ran every control example everywhere.

what:
- Guides, module READMEs and CONTRIBUTING state that managed control
  needs stable tmux 3.7 and a pinnable socket, name the 3.4 boundary
  for named buffers and managed paste, the exact-3.7 break-pane rule
  and the 3.3a layout rule, and keep the 3.3a floor for everything else.
- A `control:` directive for Java and Kotlin snippets and
  `scala-io-control` for Scala run normally from stable 3.7 and assert
  UnsupportedFeatureException with no attached client below it, so every
  lane still compiles and exercises the examples.
- The Python workspace backend section names the optional-policy marker.
why: Callers need to know which operations keep the 3.3a floor and
which are refused earlier, and what a custom ControlCarrier must now
provide.

what:
- Table of the per-feature requirement or repair, the escape-hatch
  boundary for opaque programs and aliases, and the pinSocket contract
  for custom carriers.
- The Python bridge requires optional-policy marker 2.
Notes the control floor, the 3.4 buffer boundary, the layout, split and
break refusals and the Python policy marker, with a link to the
migration notes. References issue #68.
why: Control acquisition read the daemon identity with
`display-message -p`, and the attach then confirmed it with another
display-message over the control line, as did Server.control before
it. Each fires a user's after-display-message hook, the first two
before the daemon was known to be eligible. run-shell -C cannot answer
over a control line: its parse error is printed outside any %begin/%end
block, so the block ends empty.

what:
- The identity is read by subprocess with `run-shell -C`, as the
  version probe is. tmux names only the first word it cannot parse, so
  the fields travel in one word: version, pid and start time joined by
  commas, then the socket path in single quotes with embedded quotes
  closed, escaped and reopened. More or fewer lines than one are
  refused.
- After the attach the identity is read again through the pin, by
  subprocess, and a mismatch closes the client; nothing is read over the
  control line.
- Server.control no longer reads `#{pid} #{version}`: the pinned
  acquisition compares the daemon it reaches with the captured pid, start
  time and version. The floor is decided with the hook-free probe when
  the capture carries no version.
- Cases: a socket path with a space, a quote and a comma survives; the
  hook stays quiet through qualification and recheck while a control
  `display-message` fires it.

Verified on tmux 3.7 and master (next-3.9). Reintroducing a
display-message read in the recheck fails the hook case on 3.7
("acquisition ran the hook"). Control replies are framed with
display-message markers once a client is attached; that is the client's
own traffic and unchanged.
@tony tony mentioned this pull request Oct 17, 2026
2 of 3 tasks
@tony
tony force-pushed the bump-min-tmux-000 branch from e967af3 to d34e6d0 Compare October 17, 2026 11:51
@tony
tony force-pushed the bump-min-tmux-001 branch from 171da71 to ad6d92d Compare October 17, 2026 12:35
tony added 4 commits October 17, 2026 08:08
why: The release rehearsal runs a staged consumer that reads a
session's live view. A live view is managed control, which a daemon
older than 3.7 now refuses before attaching, and the runner's tmux is
3.4, so the rehearsal failed with UnsupportedFeatureException.

what:
- The consumer treats UnsupportedFeatureException from the attach as the
  expected answer on an older daemon and still asserts one view where
  control is available.

Verified by staging the release locally and running the consumer with
tmux 3.7d (one view read) and tmux 3.4 on PATH (refusal accepted).
why: Exactly tmux 3.7 rejects `.` and `:` in session and window names.
The library passed the name through, so a batch or a workspace could
run the commands ahead of the rejected one and leave a half-built
session.

what:
- Names.require, for a version or for a server (read hook-free),
  refuses such a name on exactly 3.7 (a candidate counts, 3.7a and later
  do not) with IllegalArgumentException.
- TmuxFeatures applies it to new-session -s/-n, new-window -n and the
  new name of rename-session and rename-window, so typed calls, raw
  commands and batches are all covered, including clustered flags.
- The workspace applier checks every window name before it creates the
  staging session, as it already checks layouts.
- Cases: NamesTest over every release family; on the real 3.7 lane a
  batch whose second command carries the name leaves the first command's
  option unset and creates no window, while other releases accept it.

Verified: making Names.require accept every name fails NamesTest and the
3.7 lane case.
Adds the delimiter rule for session and window names to the optional requirements table.
Notes that names with a dot or colon are refused before dispatch on exactly 3.7. References issue #68.
@tony
tony force-pushed the bump-min-tmux-001 branch 3 times, most recently from e57f5e0 to e295b16 Compare October 17, 2026 15:44
…able

why: The watching examples attach a control client, which a daemon older
than 3.7 now refuses. The JDK checks run them against the runner's
tmux 3.4 and failed (RunnableExamplesSuite, ExamplesRunTest, MainsTest).

what:
- Those cases assume ControlAcquisition.supports (Scala: the fixture's
  supportsControl), so they run on 3.7 and newer and are skipped below.

Verified with :examples:test under tmux 3.4.
@tony
tony force-pushed the bump-min-tmux-000 branch from 8e59cbe to 32e9f85 Compare October 17, 2026 18:53
@tony
tony force-pushed the bump-min-tmux-001 branch from f8faa9f to e77e759 Compare October 17, 2026 18:53
@tony
tony force-pushed the bump-min-tmux-000 branch from 32e9f85 to 54837b8 Compare October 17, 2026 20:02
@tony
tony force-pushed the bump-min-tmux-001 branch from e77e759 to 80f1754 Compare October 17, 2026 20:07
@tony
tony force-pushed the bump-min-tmux-000 branch from 54837b8 to b569d98 Compare October 17, 2026 22:26
@tony
tony force-pushed the bump-min-tmux-001 branch from 80f1754 to f7a8e83 Compare October 17, 2026 22:26
@tony
tony force-pushed the bump-min-tmux-000 branch from b569d98 to e251f95 Compare October 18, 2026 03:39
@tony
tony force-pushed the bump-min-tmux-001 branch from f7a8e83 to 993f791 Compare October 18, 2026 03:39
@tony
tony force-pushed the bump-min-tmux-000 branch from e251f95 to b1f0e99 Compare October 18, 2026 04:00
@tony
tony force-pushed the bump-min-tmux-001 branch from 993f791 to 542daf2 Compare October 18, 2026 04:00
@tony
tony force-pushed the bump-min-tmux-000 branch from b1f0e99 to 87e4fcb Compare October 18, 2026 12:49
@tony
tony force-pushed the bump-min-tmux-001 branch from 542daf2 to 586e66d Compare October 18, 2026 12:49
@tony
tony force-pushed the bump-min-tmux-000 branch from 87e4fcb to ec52020 Compare October 18, 2026 12:50
@tony
tony force-pushed the bump-min-tmux-001 branch from 586e66d to bd1e4d8 Compare October 18, 2026 12:50

This branch has not been deployed

No deployments
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.

1 participant