Skip to content

Harden transport, MCP safety, and workspace execution - #11

Merged
tony merged 315 commits into
masterfrom
improvements-00
Aug 30, 2026
Merged

Harden transport, MCP safety, and workspace execution#11
tony merged 315 commits into
masterfrom
improvements-00

Conversation

@tony

@tony tony commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Bounded transport: Bound spawned and control-mode output, framing, retries, timers, and reconnect generations.
  • Authenticated data: Preserve exact numeric IDs, daemon evidence, typed scalar domains, and linked-window placement context.
  • Reliable output: Preserve binary buffers, terminal corrections, blank rows, capture bounds, and explicit live-stream gaps.
  • Safer MCP defaults: Remain read-only and shell-free by default, protect caller and attended panes, enforce exact allowlists, and bound commands and results.
  • Deterministic workspaces: Validate literal commands, names, options, and operation boundaries while reporting partial builds and preserving shared topology.
  • Release integrity: Verify emitted declarations, source maps, packed consumers, coordinated versions, and registry tag ordering.

Changes by area

  • Core: Reshape transport, graph, operation, selection, and runtime ownership.
  • MCP: Split policy, resources, live streams, commands, search, and settings.
  • Workspace and distribution: Strengthen workspace application, package analysis, test isolation, and release coordination.

Compatibility

  • Development moves to Bun 1.4 while CI retains the documented Bun 1.3.14 runtime floor.
  • Rewrite-prone names and malformed numeric, query, policy, and filter values now fail closed.
  • The MCP task surface is removed.

Test plan

  • Net patch and changelog pass git diff --check.
  • Formatting, linting, type checks, and unit suites pass with the declared Bun 1.4.0 toolchain.

Comment thread packages/libtmux/tests/unit/package_contract.test.ts Fixed
tony added 29 commits August 30, 2026 06:13
why: Prompt interpolation could change a pseudo-call and an empty wait pattern matched without output.

what:
- Serialize dynamic prompt arguments as JSON
- Reject empty literal wait patterns at the protocol boundary
why: Alpha readiness and package versioning had no explicit decision boundary.

what:
- require severity, runtime, platform, package, and soak evidence for 0.1.0
- split package release numbers after the coordinated stable release
why: Pane ids repeat across daemons, and terminal clients see every
split in an unzoomed window. Safety decisions need the resolved
server identity and visible pane set.

what:
- Thread the configured environment into caller identity
- Guard destructive operations with full daemon identity
- Treat visible splits, move_pane, and incompatible shells conservatively
why: The task-ceiling test relied on stale pane output even though waits intentionally start at the current stream cursor.

what:
- schedule output after the task wait starts
- keep the test fast while proving the blocking ceiling
why: Editor navigation stopped at emitted declarations because declaration
maps were disabled, while JavaScript maps referenced sources omitted from
the tarballs.

what:
- Emit declaration maps for all three published packages
- Pack mapped sources without the libtmux test harness
- Reject missing declaration maps, packed maps, and packed sources
why: Pane ranges, search fan-out, and paste buffers could allocate or
return more data than server policy allowed. Trimmed custom captures also
advertised the wrong recovery resource.

what:
- Clamp pane ranges, result bytes, and search concurrency before reads
- Reject empty searches and deduplicate linked pane ids
- Report range, byte, scrollback, and match truncation
- Stage paste buffers on disk before bounded in-memory reads
why: A window can be linked into one session more than once. A shared
tmux id lets current selection choose which placement receives a
mutation.

what:
- Address placement mutations with the handle's session and index
- Cover same-session duplicate selection against real tmux
- Assert select, move, unlink, and swap command targets
why: Marker isolation runs user code under a shell subprocess, so tmux still reports sh and cannot identify a timed-out command as busy.

what:
- retain a pane reservation until framing proves settlement
- refuse ordinary input while the reservation is active
- cover live and capture-fallback command paths
why: Paste paths could interleave with a tracked run_command and their annotations hid the host-side effects of shell input.

what:
- reserve paste_text and paste_buffer behind the command guard
- mark both paste tools as open-world operations
- extend real-server safety and annotation coverage
why: Caller-supplied maxLines could exceed the operator limit for command and wait results.

what:
- clamp both tools through the shared policy helper
- report omitted wait lines and prove the cap against real tmux
why: Snapshots repeat windows and panes for every link or group member. Strict id lookups then throw, while singular placement fields hide the actual topology.

what:
- publish one entity with sorted placements and unique resources and counts
- require exact source placements only for placement-specific operations
- share validated pane and window id schemas across owned tools
- cover duplicate links and grouped sessions against real tmux
why: A timed-out command checked its pane with a cardinality operation that rejects linked or grouped placements.

what:
- resolve entity liveness from the first matching placement
- cover a timed-out command on a multiply linked pane
why: Several public tool, result, and prompt schemas still advertised arbitrary strings where tmux requires branded id syntax.

what:
- share pane, window, and session id schemas across MCP surfaces
- inspect every published tool schema and reject invalid prompt ids
why: Session-filter coverage still read the removed singular placement fields after linked entities gained placement arrays.

what:
- assert each filtered entity includes the requested session placement
why: Command fallback, observe seeding, missed-wait screens, and pane resources could serialize or return a huge grid before line trimming.

what:
- centralize line-and-byte-bounded grid capture
- expose byte, range, and completeness metadata
- cover live, fallback, wait, observe, and resource paths
why: A fixed one-second delay raced process exit under the full loaded suite.

what:
- poll the public pane view to a bounded deadline
- preserve the immediate dead-pane refusal assertion
why: Omitting timeoutMs on a task used the 30-second blocking default instead of the advertised ten-minute task default.

what:
- select the default from the active wait form
- cover both omitted-timeout paths
why: Later windows ignored the first pane start directory and used only the window or workspace fallback.

what:
- Resolve every created window from pane zero first
- Cover later-window precedence with real tmux
why: Public hook flags promised window and pane scopes that typed handles could not reach.

what:
- Add hook reads and mutations to Window and Pane
- Drive hook and option commands from public scope maps
- Prove all four hook scopes against real tmux
why: Metadata tools and JSON resources could return unbounded tmux-derived strings even after pane reads gained fixed limits.

what:
- cap settings, discovery, and format responses by line and byte ceilings
- disclose omitted entries and preserve complete structured prefixes
- bound JSON resource collections without changing their normal-size shape
- cover structured, text, and nested resource payloads
why: Setup examples bypassed the exact-alpha warning, and wait and cursor guidance described contracts the implementation rejects.

what:
- Pin every package-launch example to alpha.6
- State tier authority and cancellation semantics
- Use a valid opaque live cursor
why: Session-qualified window targets contain a daemon-local session ID but bypassed stale-handle guards.

what:
- Recognize IDs that prefix exact placements
- Prove qualified window targets require guards
why: Reconcilers need to remove one linked placement or a final window without ever mutating a grouped window list.

what:
- Add direct and planned removePlacement operations
- Refuse grouped targets inside the tmux command queue
- Prove linked, last, and grouped behavior on real tmux
why: A pane kill mutates every placement of a linked or grouped window, so conservative reconcilers need an atomic refusal.

what:
- Add direct and planned guarded pane kills
- Check window linkage inside the tmux command queue
- Reuse the conditional mutation framing
why: Pruning destroyed linked and grouped entities outside the workspace session, while name-keyed plans lost duplicate operations.

what:
- Unlink independent placements and retain grouped windows
- Retain panes whenever their window is shared
- Return ID-keyed structured immutable plan entries
- Cover linked, grouped, and duplicate-name cases on real tmux
why: The worked example reimplemented a different builder, and the README omitted command, option, sharing, and partial-plan boundaries.

what:
- Drive the example through @libtmux/workspace
- Document create-only commands and option ownership
- Explain structured plans and shared-topology retention
- Pin every alpha installation example
why: Generated API documentation did not yet expose the new hook and placement-safe mutation methods.

what:
- Document window and pane hook methods
- Document guarded pane and placement removal
why: Plain numbers let fractions, infinities, and unsafe integers satisfy declarations that the runtime encoder rejects.

what:
- Brand decoded numeric fields and authored numeric criteria as SafeInteger
- Export runtime guards for authenticating computed values
- Regenerate criteria types and migrate package consumers
why: SplitOptions admitted fractions, infinities, oversized cells, and percentage text that tmux cannot interpret.

what:
- Brand validated cell counts and enumerate exact percentage literals
- Export split-size guards and authentication
- Keep operation-boundary validation against forged inputs
tony added 27 commits August 30, 2026 06:13
why:
The table held 22 of the 25 classes `u`-mode regex folds together, and
the test that claimed to derive it from the engine could not have found
the other three. It proposed candidates by round-tripping a code point
through case, and U+1FD3, U+1FE3 and U+FB06 uppercase to more than one
code point, so the single-character pattern it tested never matched.

what:
- Add U+1FD3, U+1FE3 and U+FB06, derived twice over: by bucketing on NFC
  and NFKC as well as case, and independently from CPython's casefold
  groups, which agree on the same 25 classes and no others
- Bucket the test the same way, and escape each member by code point,
  because the buckets contain regex metacharacters
- Report every disagreeing pair at once rather than asserting per pair
why:
The sweep removed every scratch directory beside a README before making
its own, so two concurrent runs would have one delete the other's live
directory mid-check. The repository already reaps by ownership rather
than by name, and this did not.

what:
- Name the directory for the process that made it, and reap one only
  once signal 0 reports that process gone
- Keep a directory whose pid has been reused, which fails closed
why:
The resolver matched only `{@link target}`. TSDoc also spells it `{@link
target | text}`, which the pattern skipped entirely, so that form would
have reached the page as raw markup and passed the gate meant to prevent
exactly that. The set of names it checks a target against was built from
every brace pair in `src/index.ts`, which is a barrel today and need not
stay one.

what:
- Match the piped form, render the label as the link text, and hold the
  target to the same resolution
- Read the export set from `export { ... }` statements rather than from
  any brace pair
why: The MCP and workspace suites ran one file at a time. testParallelism
existed for exactly this bound and only the library's two runners called
it, because it lived in a script those two share and the other packages
cannot reach. The MCP suite took 200-230s serially; capped at four it
takes 145s on half the cores.

what:
- Move testParallelism to _internal/test/supervisor.ts and export it from
  the testkit, the entrypoint all three packages already import
- Name the environment variable TEST_PARALLEL_ENV rather than repeating it
- Pass --parallel from the MCP and workspace runners
- Point the library's runners and the unit test at the testkit
why: A subscriber's buffer evicted with Array.shift, which moves every
remaining entry, and paid it on each notification once full — the case a
bound exists for. Pushing 200k events past a consumer taking 32 of every
64 fell from 18.9ms to 11.6ms at the default bound, and 18.4ms to 10.2ms
at 8192.

what:
- Add EventRing: bounded, drop-oldest, slots cleared on read so a drained
  subscriber stops retaining payloads
- Grow toward the bound rather than allocating it, so a waitFor holds what
  it saw rather than what it was allowed
- Cover which events survive a drop and both wrap orders; a count alone
  cannot tell a ring that lost its head from one that did not
why: tmux has reported a subscribed format's value since 3.2, below this
package's 3.2a floor, and the parser had no case for it — the one push
channel for format values arrived as an unknown line, leaving polling as
the only way to read one.

what:
- Add TmuxSubscriptionEvent, carrying the object the format expanded
  against; a session-scope report names no window or pane
- Take the value from the first ` : ` rather than by counting fields, which
  are reserved between the name and that separator, and may recur in a raw
  value
- Rename parseAge to parseUnsigned, which is what both callers read
why: Reading a format as it changes meant polling, because tmux's own
push channel for it was unreachable. A subscription lives on the control
client, so a reconnect drops it silently — re-issuing after attach is
what makes the capability survive the connection it rides on.

what:
- Add WatchOptions.subscriptions and ConnectedServer.subscribeFormat and
  unsubscribeFormat, carried as refresh-client -B on the observer's
  spawned command like pause-after
- Re-issue every subscription once tmux accepts an attach, beside the
  pause-after the same reconnect would otherwise lose
- Refuse a name holding ":", whitespace, or a control character, and a
  format holding a line break: tmux answers all four by discarding the
  request or emitting a report whose fields cannot be told apart
- Take scope as a pane or window id, "all-panes", or "all-windows"
- Merge the duplicate testkit import the runner had picked up
why: The release check compared the run root against a reading taken
before the work, so a reservation a sibling test file made afterwards
counted as this one's leak — and it waits on a fixture still in use, so
the deadline always passed. It failed 2 of 3 runs at bca4286, naming
reservations belonging to two other processes.

what:
- Filter by the reservation prefix, which already carries the owning
  process id, and export it from fixture_launch so the harness asks the
  module that builds the name rather than rebuilding it
- Rename to waitForOwnReservations, which is what it now answers
- Drop the baseline from all seven call sites
why: `scope: undefined` is not assignable under
exactOptionalPropertyTypes, and the package's `typecheck` covers `src`
only — the tests are compiled by `typecheck:tooling`, which I had not
run.

what:
- Omit the property rather than passing undefined for it
why: packages/mcp and packages/workspace each include src and tests, so
their runner scripts were compiled by nothing. A deliberate type error in
mcp/scripts/run-tests.ts passed every gate in the workflow — the same
blind spot the gates job already names for the repository's own scripts,
where a gate that crashes on its own type error reports no failures.

what:
- Add scripts/**/*.ts to both packages' tsconfig include, so the existing
  typecheck gate covers them
why: 820 lines pointing agent CLIs at a build of @libtmux/mcp lived in
the library package, which neither owns the tool nor is its subject. Root
scripts is where cross-package tooling already is, and publish-release
shows the convention: the script there, its unit test in the library's
suite.

what:
- Move scripts/mcp_swap.ts to the repository's scripts directory, where
  bounded_process becomes a sibling import
- Repoint the test, which stays where publish_release.test.ts is
- Stop exporting reservationsIn, which nothing outside its module reads
  since the release check began asking by owner
why: The capability had no entry point in the document a reader meets
first, where every other part of watching is introduced. The block is
executed by the README gate, so the example is also proof.

what:
- Add "Subscribing to a format" under Watching, covering the once-a-second
  evaluation, report-on-change, scope, and re-issue after a reconnect
why: A reconnect's replacement child exists before tmux accepts its
attach, so subscribeFormat landing in that window addressed a client tmux
did not know yet and came back as a refusal rather than as the reconnect
it was. A new name arriving while #finishAttach was still iterating was
also issued twice, because a Map iterator visits keys inserted during
iteration — and the first evaluation always reports, so that is a
duplicate report to every subscriber.

what:
- Defer a subscription change while reopening or before the attachment
  settles; #finishAttach issues the stored set once the attach lands
- Cover the re-issue against a replacement client, which nothing did: the
  guarantee was documented in TSDoc and the changelog and would have
  regressed in silence
- Name the configuration step in #finishAttach's fallback message, which
  has covered more than pause-after since subscriptions joined it
why: collectChild killed the helper at a flat 750ms, the one bound in the
fixture that LIBTMUX_TEST_DEADLINE_SCALE could not reach — and it bounds
the slowest child there is, an interpreter start spawned while several
test files run at once. Past it the daemon goes unreaped and its fixture
leaks, which surfaces in whichever test ran next. The report then said
"pidfd cleanup refused", which is the helper declining after checking
identity, not the helper being killed before it looked.

what:
- Name PIDFD_HELPER_DEADLINE_MS in deadlines.ts and route the escalation
  ladder through deadlineMs, so a busy machine can scale it
- Report a helper killed by a signal as that, naming the signal
why: tests/unit.test.ts held sixteen unrelated describes in 1522 lines
and was the most-edited file on this branch, so every change to any of
those subjects met every other one. Its sibling server.test.ts was
already split by subject; this is the same split for the same reason.
Named for a category rather than a subject, it also had nowhere to put
the next test.

what:
- live_hub, command_framing, pane_text, policy, guidance and results,
  each named for what it covers
- Keep the fakes with live_hub, the only describe that used them
- 277 tests before and after, now across 22 files rather than 17
why: The injected-hang mode gives the emitted-Node child 45 seconds to
reach its failure point, a figure sized against the scenarios ahead of it
on an idle machine — and the comment beside it already says racing those
kills the child before it creates the fixture whose cleanup is the thing
under test. It was read as the bound rather than as a base, so
LIBTMUX_TEST_DEADLINE_SCALE could not reach it, the same gap the pidfd
helper had. The one failure in eighteen integration runs was this test
reporting it never reached the injection.

what:
- Scale what test-node reads, so the value a caller sizes on an idle
  machine grows with the machine it runs on
- Say in the test that the runner does the scaling
why: Six spawns of tmux on the fixture's launch and cleanup paths carried
a flat bound that LIBTMUX_TEST_DEADLINE_SCALE could not reach, the same
gap the pidfd helper and the emitted-Node budget had. Each is a liveness
bound — spawn tmux, read display-message back — and expiring one on the
cleanup path leaves the daemon it was about to reap, which surfaces as a
leak in whichever test ran next.

Scaled, not lengthened: the probe bound stays a second because it sits on
the path a later test is blocked behind, where deadlines.ts already says
every millisecond is one the fixture holds its reservation.

what:
- Name FIXTURE_PROBE_DEADLINE_MS at the reaper and launch probe's second,
  and FIXTURE_BOOTSTRAP_DEADLINE_MS at test_server's three
- Route both through deadlineMs, leaving every value unchanged at scale 1
why: A clean checkout ran the type-aware fixture before generated
declarations existed, so both supported Bun lanes failed during lint.

what:
- Build the library through the lint prehook
why: Nested runners inherit the owner's run root, so sweeping that
namespace can race the owner or an explicit reaper.

what:
- Share the run-root environment key across runners
- Sweep only when no owner supplied a root
- Include reaper output when its status fails
why: Current tmux development builds report next-VERSION, while the
parser accepted any string containing master and rejected that form.

what:
- Accept exact master, next-VERSION, and VERSION-master forms
- Reject lookalikes and keep development builds above releases
- Update public API documentation
why: CodeQL flagged the single expression used to validate package
versions as an inefficient and ambiguous repetition.

what:
- Validate core, prerelease, and build identifiers in bounded steps
- Cover canonical and rejected SemVer forms
Why: The boundary gate depended on ripgrep even though CI did not install it, so Bun 1.4 stopped at lint before checking the repository.

What: Enumerate tracked and untracked source files with the Git executable guaranteed by checkout.
why: tmux 3.2a right-pads captured screen rows, which made started and completed commands look timed out.

what:
- Ignore terminal padding after ready and completion markers
- Cover padded, wrapped fallback captures and command outcomes
why: The MCP suite exercises Zsh trap preservation, but the Ubuntu gate only installed fish.

what:
- Install both required test shells in CI
- Fail once with a precise missing-shell diagnostic
why: subscription methods changed the reconnect ledger before tmux accepted an update, and same-name calls could reach tmux out of order.

what:
- Serialize subscription changes by name
- Commit immutable desired state only after live acceptance
- Reconcile updates that overlap control-client attachment
- Cover refusals, concurrent removal, and attach-time replacement
why: the source comment narrated an earlier leak instead of stating the cursor and connection-lifetime contract the code enforces.

what:
- Replace branch history with the current tail-expiry invariant
Keep public format changes pending until tmux accepts them on the current attached child. Retry uncertain delivery on a replacement child, preserve per-name ordering across reconnects, and reject active changes when the connection closes.
@tony
tony force-pushed the improvements-00 branch from 06e1aca to 9e56211 Compare August 30, 2026 13:50
@tony
tony merged commit 3c25fd8 into master Aug 30, 2026
16 checks passed
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.

2 participants