Skip to content

feat(runtime): bundle tmux with packaged Agent Code - #125

Merged
Juliusolsson05 merged 2 commits into
mainfrom
feat/bundle-tmux-static
May 14, 2026
Merged

Juliusolsson05 merged 2 commits into
mainfrom
feat/bundle-tmux-static

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Closes #120. Companion to #119 (mitmdump bundling). With this merged, both core helpers ship inside packaged Agent Code — no Homebrew install required for proxy streaming or terminal-pane persistence.

Key design decision

The original plan for #120 was to build static tmux from source (tmux + libevent + ncurses + utf8proc), which the review-agent on #121 flagged as the higher-risk of the two bundling efforts because of ncurses terminfo and utf8proc static-linking gotchas.

Research turned up tmux/tmux-builds — the official upstream prebuilt-binary repo maintained by the tmux project itself. macOS binaries:

  • 1.6 MB single binary, no archive structure beyond a one-file tar.gz
  • Link ONLY to `/usr/lib/libSystem.B.dylib` and `/usr/lib/libresolv.9.dylib` (verified with `otool -L`)
  • Pass the full lifecycle proof from the review (`tmux -V`, `new-session`, `send-keys`, `capture-pane` returning `term=tmux-256color cols=80`, `kill-session`) cleanly without external dependencies
  • Maintained on the upstream tmux project's own CI

Owning a custom static build for what already exists upstream is unnecessary maintenance, so this PR adopts the upstream artifact as-is. The static-build path stays documented in case upstream ever stops publishing.

Repo plumbing

Bundled artifact (mirrors mitmproxy's shape from #119)

  • `third_party/tmux/manifest.json` — pins `tmux 3.6a`, per-arch archive sha256 + bytes
  • `third_party/tmux/README.md` — updated from PR-1 stub to real description, including upstream sourcing rationale
  • `third_party/tmux/LICENSE.md` — tmux ISC, unchanged
  • `third_party/tmux/.gitignore` — `cache/` excluded
  • `scripts/runtime-tools/fetch-tmux.mjs` — downloads pinned archive, sha256-verifies, extracts inner binary, chmod +x, discards archive. Idempotent via a `tmux -V` cache hit.
  • `scripts/runtime-tools/verify-tmux.mjs` — verifies binary is executable, `tmux -V` matches the pin, and `otool -L` shows NO Homebrew dylib linkage. Strict mode is the CI gate.
  • `scripts/runtime-tools/build-tmux.mjs` — deleted. No longer needed.
  • `package.json` — `runtime:build:tmux` → `runtime:fetch:tmux`; `runtime:prepare:mac` now fetches + strict-verifies both tools.

Runtime resolver

  • `src/main/setup/runtimeTools.ts` — `resolveBundledTool('tmux')` and `isBundledArchiveAvailable('tmux')` implemented. Because tmux is a single binary, the resolver returns the asar-unpacked path directly — no extraction, no userData copy, no install marker (unlike mitmproxy's multi-file .app, which needs atomic temp+rename). Defensive `chmod 0o755` on each call rules out electron-builder signing dropping the executable bit.

Wiring

  • `src/main/tmux/TmuxRegistry.ts` — adds `getBinary()` accessor.
  • `src/main/index.ts` — `new TmuxRegistry({ tmuxBinary: await resolveBundledTool('tmux') })`. Bundled-only: no `getToolPath('tmux', 'tmux')` fallback. If the binary is missing, tmux availability resolves to false and terminals fall back to direct PTY — same behaviour as a machine without tmux. No silent system-tmux usage.
  • `src/main/sessionManager.ts` — threads `tmuxRegistry.getBinary()` into `TerminalSession` so attach-as-child-PTY uses the SAME binary the registry used for `new-session`.

Setup-gate cleanup

  • `src/shared/types/setup.ts` — `'tmux'` removed from `SetupToolId` and `SetupInstallTarget`.
  • `src/main/setup/prerequisites.ts` — drops tmux from `TOOL_META` and `CHECK_ORDER`. Bundled tools are not a user-installable concept.
  • `src/main/setup/binaryResolver.ts` / `homebrewInstaller.ts` / `ipc/setup.ts` — drop the tmux entries.
  • `src/renderer/src/features/setup/ui/SetupGate.tsx` — drops the tmux row.

Verification

  • `npm run runtime:fetch:tmux -- --all` — clean, both arches.
  • `npm run runtime:verify:tmux -- --strict` — `darwin-arm64` reports `(3.6a, no Homebrew linkage)`; `darwin-x86_64` presence-only OK (can't run cross-arch `tmux -V` from arm64).
  • `npm run build:app` — clean. `out/main/runtime/tmux//tmux` lands at a real filesystem path with the executable bit set.
  • Lifecycle proof against the upstream binary (the review-agent's risk gate): `tmux -L probe new-session -d -s t /bin/zsh` → `send-keys 'echo $TERM cols=$(tput cols)'` → `capture-pane` returns `term=tmux-256color cols=80`. Terminfo works out of the box. `kill-session` clean.

Size impact

DMG grows by ~1.3 MiB total (`tmux-3.6a-macos-arm64.tar.gz` 624 KB + `tmux-3.6a-macos-x86_64.tar.gz` 650 KB). No userData footprint — we spawn from the asar-unpacked path directly.

Follow-up

A separate cleanup PR will give mitmproxy the same treatment (drop the setup-gate visibility + PATH fallback now that the bundled artifact is canonical, per the discussion that motivated this PR's setup-gate cleanup). tmux landed first because #120 demanded it; mitmproxy was already bundled in #119, so the cleanup is a pure "remove redundant fallback" change.

Test plan

  • Run packaged app on a clean macOS profile without Homebrew tmux installed → terminals persist across restarts.
  • Run in dev with `npm run runtime:prepare:mac` → bundled tmux is used; `ps` / `lsof` shows the spawn comes from `out/main/runtime/tmux/darwin-arm64/tmux`.
  • Run in dev WITHOUT `runtime:prepare:mac` → tmux availability is false, terminals fall back to direct PTY, no silent Homebrew-tmux usage.
  • SetupGate visual: tmux row gone.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits May 14, 2026 19:24
Packaged Agent Code now ships its own tmux 3.6a so terminal-pane
persistence works on user machines without any Homebrew install. Same
shape as #119 (mitmdump bundling): manifest-pinned, hash-verified,
fetched on demand, never committed.

Key design decision — use upstream's prebuilt binary, NOT a custom
static build. The original plan was to build statically from source
(tmux + libevent + ncurses + utf8proc) per the review-agent's risk
flag about terminfo/ncurses gotchas. While researching, found
`tmux/tmux-builds` — the official upstream prebuilt-binary repo
maintained by the tmux project itself. Their macOS binaries link
ONLY to /usr/lib/libSystem.B.dylib and /usr/lib/libresolv.9.dylib
(guaranteed present on every macOS install), and pass the full
lifecycle proof (`tmux -V`, new-session, send-keys, capture-pane
with `tput cols`, kill-session) cleanly without external
dependencies. Owning a custom build for what already exists upstream
is unnecessary maintenance; we adopt the upstream artifact as-is.

What changes:

- `third_party/tmux/manifest.json` — pins tmux 3.6a with per-arch
  archive sha256 + bytes from `tmux/tmux-builds` release v3.6a.
  Schema is identical to mitmproxy's manifest; reviewers see the
  same shape.
- `scripts/runtime-tools/fetch-tmux.mjs` — downloads pinned archive,
  verifies sha256, extracts the inner `tmux` binary to
  `cache/<platform>/tmux`, sets executable bit, discards the
  archive. Idempotent — re-runs hit the version cache.
- `scripts/runtime-tools/verify-tmux.mjs` — rewritten: verifies
  binary is executable, `tmux -V` reports the pinned version, and
  `otool -L` shows NO Homebrew dylib linkage. Strict mode is the CI
  gate; default mode warns.
- `scripts/runtime-tools/build-tmux.mjs` — DELETED. No custom build
  needed.
- `src/main/setup/runtimeTools.ts` — implements `resolveBundledTool
  ('tmux')` and extends `isBundledArchiveAvailable` for tmux.
  Because tmux is a single self-contained binary (no .app, no
  symlinks), the resolver returns the asar-unpacked path directly —
  no extraction, no userData copy, no marker. Defensive chmod 0o755
  on each call to rule out electron-builder signing dropping the
  executable bit.
- `src/main/tmux/TmuxRegistry.ts` — adds `getBinary()` accessor so
  SessionManager can forward the registry's resolved binary into
  TerminalSession; using the SAME binary for `new-session` and
  `attach` is critical when the bundled and system tmuxes diverge.
- `src/main/index.ts` — `new TmuxRegistry({ tmuxBinary: await
  resolveBundledTool('tmux') })`. Bundled-only; if the binary is
  missing (dev without `runtime:prepare:mac`), tmux availability
  resolves to false and terminals fall back to direct PTY mode —
  same behaviour as a machine without tmux. No silent PATH fallback.
- `src/main/sessionManager.ts` — threads `tmuxRegistry.getBinary()`
  into `TerminalSession`.

Setup-gate cleanup (tmux is no longer user-installable):

- `src/shared/types/setup.ts` — removes 'tmux' from `SetupToolId`
  and from `SetupInstallTarget`.
- `src/main/setup/prerequisites.ts` — drops tmux from `TOOL_META` and
  `CHECK_ORDER`. The runtime resolver is the sole source of truth
  for tmux availability now.
- `src/main/setup/binaryResolver.ts` — drops the tmux command entry.
- `src/main/setup/homebrewInstaller.ts` — drops the tmux Homebrew
  install line.
- `src/main/ipc/setup.ts` — narrows the `setup:install` target check
  to `mitmproxy` only.
- `src/renderer/src/features/setup/ui/SetupGate.tsx` — drops the tmux
  entry from `OPTIONAL_INSTALL_TARGET`.

Follow-up tracked: mitmproxy will get the same setup-gate cleanup
treatment as part of the runtime-tools-cleanup PR. tmux landed first
because #120 demanded it; mitmproxy was already bundled in #119, so
the cleanup is a pure remove-PATH-fallback PR.

Verified (run in worktree):
- `npm run runtime:fetch:tmux -- --all` — clean, both arches.
- `npm run runtime:verify:tmux -- --strict` — darwin-arm64 reports
  "3.6a, no Homebrew linkage"; darwin-x86_64 presence-only OK.
- `npm run build:app` — clean; `out/main/runtime/tmux/<arch>/tmux`
  ends up at a real filesystem path with the executable bit set.
- Lifecycle proof against the upstream binary (the original review
  ask): `tmux -L probe new-session -d -s t /bin/zsh`, `send-keys`,
  `capture-pane` returning `term=tmux-256color cols=80`,
  `kill-session` — all green. Terminfo handling works without any
  embedded TERMINFO_DIRS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l path

Three fixes from the review on #125, all must-fix:

1. fetch-tmux.mjs spawned the extracted binary for both cache-hit
   detection and final validation, even on cross-arch fetches under
   `--all`. On a clean Apple-Silicon host without Rosetta, running an
   x86_64 Mach-O fails with "Bad CPU type in executable" — the
   release builder would crash trying to validate the x86_64 artifact
   it just downloaded. Cache-hit detection by `tmux -V` was also too
   loose: a corrupted-but-still-runnable binary that prints the right
   version string would pass.

   The fix: pin `binarySha256` per platform in
   `third_party/tmux/manifest.json` and use it for ALL identity
   checks. Cache hits, post-extract validation, and cross-arch
   `verify-tmux --strict` are now hash-based and platform-agnostic.
   The native arch additionally runs `tmux -V` + `otool -L` as a
   smoke check, but the load-bearing identity is the hash. A swapped
   or corrupted cross-arch binary that previously passed
   presence-only verification will now fail strict.

2. verify-tmux.mjs's cross-arch path was presence-only ("the file
   exists"), letting a corrupted x86_64 binary on an arm64 builder
   slip past strict mode. Same fix as #1: hash the cached binary
   against `binarySha256`. Strict mode now actually verifies content
   identity for every arch.

3. Bundled-only policy was leaky: src/main/index.ts passed the
   sentinel string `'tmux-bundled-missing'` to TmuxRegistry, which
   then spawned that bare command name. `child_process.spawn` PATH-
   searches bare names, so in the (extremely unlikely) case of a name
   collision the system would invoke whatever happened to be on PATH
   — exactly the silent fallback the bundled-only stance is meant to
   prevent.

   The fix: TmuxRegistry's `tmuxBinary` is now genuinely optional.
   When undefined, `detectAvailability()` short-circuits to false
   WITHOUT spawning anything. `getBinary()` returns `string | null`.
   src/main/index.ts passes `undefined` when bundled resolution
   returns null. No sentinel, no PATH search, no fallback.

Also: softened the `chmod 0o755` comment in resolveTmux per the
review — it no longer claims electron-builder definitively drops the
exec bit, just notes that the defensive chmod is a cheap safety net
for a class of "works in dev, broken in DMG" bugs that's much easier
to keep than to re-add later.

Verified (run in worktree):
- `npm run runtime:fetch:tmux -- --all` — both platforms cache-hit
  on rerun via the new binarySha256 path (no binary spawn for either
  arch).
- `npm run runtime:verify:tmux -- --strict` —
    darwin-arm64: `OK (3.6a, hash + linkage clean)`
    darwin-x86_64: `OK (hash matches; cross-arch)`  ← was presence-only
- `npm run build:app` — clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Pushed a63e558 addressing the three findings from the review.

1. Cross-arch binary execution → hash-based identity (fetch-tmux.mjs, verify-tmux.mjs, third_party/tmux/manifest.json)

Pinned binarySha256 per platform alongside the archive hash. All identity checks — cache-hit detection, post-extract validation, cross-arch strict verification — are now hash-based and platform-agnostic. No more tmux -V spawn on the non-native arch, so a clean Apple-Silicon CI host without Rosetta can now legitimately validate the x86_64 artifact it just downloaded.

2. Loose cross-arch verification → real cross-arch verification (verify-tmux.mjs)

Same fix as #1. Strict mode's darwin-x86_64 row now reports OK (hash matches; cross-arch) instead of presence-only. A corrupted or swapped cross-arch binary that previously slipped past strict will now fail it. The native arch keeps the additional tmux -V + otool -L smoke checks layered on top of the hash gate.

3. Sentinel path → genuine optional (src/main/tmux/TmuxRegistry.ts, src/main/index.ts, src/main/sessionManager.ts)

Replaced the 'tmux-bundled-missing' sentinel. TmuxRegistry.tmuxBinary is now genuinely optional; when undefined, detectAvailability() short-circuits to false without spawning anything. getBinary() returns string | null. src/main/index.ts passes undefined when resolveBundledTool('tmux') returns null. Zero PATH lookups, zero spawn attempts, zero name-collision risk.

Also softened the chmod 0o755 comment in resolveTmux per your note — it no longer claims electron-builder definitively drops the exec bit, just frames the defensive chmod as a cheap safety net.

Verification on this commit

  • npm run runtime:fetch:tmux -- --all: both arches hit the new content-hash cache path on rerun (no binary spawn).
  • npm run runtime:verify:tmux -- --strict:
    • darwin-arm64: OK (3.6a, hash + linkage clean)
    • darwin-x86_64: OK (hash matches; cross-arch)
  • npm run build:app: clean.

Pre-merge test plan items (clean-packaged-app, dev-without-prepare, SetupGate visual) still owed per your review.

@Juliusolsson05
Juliusolsson05 merged commit 650c8f7 into main May 14, 2026
@Juliusolsson05
Juliusolsson05 deleted the feat/bundle-tmux-static branch July 7, 2026 21:51
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.

Bundle tmux with packaged app

1 participant