Skip to content

fix: runtime-robustness tail — boot grace, LAN UX, PTY locale, noexec probe, ISO dumps (#495) - #507

Merged
Juliusolsson05 merged 6 commits into
mainfrom
fix/runtime-robustness-tail
Jul 9, 2026
Merged

Juliusolsson05 merged 6 commits into
mainfrom
fix/runtime-robustness-tail

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

What

Part 4 of the #495 Tier-A portability plan — the runtime tail, one commit per finding:

  • A10 — acquireStateProcessLock() was the first STATE_DIR write and an EACCES/EROFS/EPERM there crashed boot raw, while the already-running case right next to it got a friendly dialog. Those errnos now get their own dialog.showErrorBox naming the directory + app.exit(1) (not app.quit() — will-quit handlers write into the same unwritable state). Everything else still rethrows into the fatal-startup incident handler. Retention was NOT rebuilt — debugRetention.ts already covers it (audit correction).
  • A11 — raw Node bind errors ("listen EADDRINUSE …") are translated into actionable copy at doEnable's rethrow (original error kept as cause); the Remote panel shows a static firewall hint whenever LAN is live, because a firewall drop happens after a successful bind and is undetectable desktop-side. No auto-fallback to Tunnel — the controller's fail-loud policy is deliberate and unchanged.
  • A12 — PTY env now defaults LANG=en_US.UTF-8 when the inherited locale is unset/C/POSIX (Finder-launched apps often have none; downstream stream parsing assumes UTF-8). An explicit user locale is never overridden.
  • A13+A14 — /usr/bin/tar hardcode replaced with a cached resolver (absolute path → PATH); bundled tmux/cloudflared/mitmdump get a cached-per-path real-exec version probe that turns the access(X_OK)-passes-but-execve-EACCES noexec signature into a typed BundledToolExecProbeError naming the mount suspicion. mitmdump probes its extraction tmp path before the atomic rename, so a refused binary never becomes the marked install. processLock.ts's ps usage was left alone — already portable (audit correction). Deviation from the plan, recorded in the commit: the typed error is logged + degraded to null rather than rethrown, preserving resolveBundledTool's documented null-contract (a throw would abort boot over an optional feature).
  • A15 — persisted worktree-dump timestamps are now toISOString() (locale+TZ-dependent output made byte-identical states diff across machines).

A7 (mitmdump port TOCTOU) rides separately in the claude-code-headless submodule: Juliusolsson05/claude-code-headless#36. The submodule pointer bump lands here (or in a follow-up commit on this branch) once that PR merges.

Verification

npx tsc -b tsconfig.node.json and npx tsc --noEmit -p tsconfig.web.json green on the final tree (re-run independently by the reviewer/orchestrator). Behavioral checks that need a hostile environment (unwritable STATE_DIR, occupied port, noexec mount) are typecheck-verified only — flagged for manual testing.

Refs #495 (A10–A15). Plan: docs/superpowers/plans/2026-07-09-portability-tier-a-any-mac-hardening.md.

🤖 Generated with Claude Code

Juliusolsson05 and others added 5 commits July 9, 2026 10:33
…ritable (#495 A10)

acquireStateProcessLock() is the first write into ~/.config/agent-code and
was called unguarded at startApp()'s top. On any Mac account where that
directory can't be written (wrong ownership after a Time Machine restore or
account migration, read-only/full volume, MDM policy) the mkdir/open threw
EACCES/EROFS/EPERM straight up into an unhandled-rejection crash — while
the neighboring already-running case had a friendly showErrorBox. First
launch on a hostile account therefore looked like a broken install with
zero explanation.

Fix: wrap only the lock acquisition in try/catch and, for the permission
errno family (EACCES/EROFS/EPERM), show an error box naming the actual
STATE_DIR path plus what to check, then app.exit(1). exit() rather than
quit() because will-quit handlers write into the same unwritable state
(clean-shutdown marker, lock release) and would only cascade more failures
— nothing was acquired, so there is nothing to release. Every other error
still rethrows into the existing fatal-startup handler that journals an
incident: crash-loudly remains correct for unknown corruption.

Scope notes (per the #495 plan): debug retention is NOT touched — a
budgeted TTL/cap sweep already runs at startup via debugRetention.ts;
XDG_STATE_HOME/XDG_CONFIG_HOME honoring is #496 B10 and paths.ts stays
as-is here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el (#495 A11)

Two halves of the same UX gap: when LAN serving fails or half-works on a
locked-down Mac, the user got either a raw Node errno string or nothing.

1) RemoteController.doEnable's rethrow reaches RemotePanel's error line
verbatim, so "listen EADDRINUSE: address already in use 0.0.0.0:52341"
was the entire user-facing story. A message mapper (translateBindError)
now converts exactly two errno families into copy that says what to do:
EADDRINUSE -> retry (LanTransport picks a fresh OS-assigned port per
enable), EACCES/EPERM -> check the macOS firewall. Everything else —
including buildTransport's deliberate loud tunnel-unavailable error —
passes through untouched, the original error rides as `cause` for
journals/devtools, and the rethrow-after-teardown semantics are
unchanged. No auto-fallback to Tunnel: that would contradict the
controller's documented fail-loud transport policy (the user asked for a
specific reachability; silently giving them another misrepresents what
the QR exposes).

2) The nastier case binds SUCCESSFULLY: the macOS application firewall
drops inbound packets after a successful listen, so the desktop cannot
detect it — the phone just times out. Desktop-side detection being
impossible, the honest fix is a static muted hint under the LAN "Serving
at" status (rendered only while LAN transport is live) pointing at the
firewall setting and the existing Tunnel toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… has none (#495 A12)

Terminals spawned from a Finder/Dock-launched Electron app inherit
launchd's environment, which frequently carries NO locale variables at
all (or an explicit C/POSIX locale on some managed accounts). Shells and
agent CLIs then fall back to the C locale and emit mangled/escaped
non-ASCII bytes — box-drawing, emoji, non-English filenames — that the
UTF-8 stream parsing downstream of the PTY trusts implicitly. In dev
(`npm run dev` from a terminal) the user's locale leaks through and hides
the bug, which is exactly the works-on-my-machine shape #495 catalogs.

Fill env.LANG = 'en_US.UTF-8' ONLY when the user expressed nothing:
LC_ALL ?? LC_CTYPE ?? LANG unset, or set to C/POSIX (the "nothing
configured" defaults, not a preference). An explicit non-UTF-8 locale is
the user's choice and passes through untouched, and the extraEnv override
loop still runs after this so integration callers can override LANG too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent tar resolution (#495 A13, A14)

A13 — access(X_OK) proves mode bits, not that the kernel will execve: a
volume mounted noexec (network home, hardened corporate image) passes
the access check and then fails the real spawn with EACCES far
downstream — inside tmux detection or the cloudflared tunnel — where
nothing points at the mount. Add probeExecutable(): after the existing
chmod+access checks, run the binary once with the same version flag its
fetch script's smoke test uses (verified in scripts/runtime-tools/:
tmux `-V`, cloudflared `--version`, mitmdump `--version`) and translate
EACCES-after-X_OK-passed (the noexec signature) into a typed
BundledToolExecProbeError naming the suspicion. Results (including
failures — a noexec mount doesn't heal mid-process) are cached per
binary path in module state, so each path probes exactly once per
process.

Deliberate deviation from a literal reading of the plan: the resolvers
CATCH the typed error, console.error it, and return null rather than
rethrowing. resolveBundledTool's documented contract is null = "not
usable, degrade" and both callers depend on it: the tmux caller in
main/index.ts is awaited unguarded at boot (a throw would abort startup
via the fatal-startup handler over a missing optional feature), and
cloudflared's null already produces a visible tunnel-unavailable error
while LAN keeps working. The typed error still exists and carries the
diagnosis — it now lands in the log/journal instead of tearing down the
feature's graceful degradation.

mitmdump probes the freshly extracted tmp path BEFORE the atomic rename,
so a refused binary never becomes the marked canonical install. The
marker fast path is deliberately NOT probed: mitmdump is a PyInstaller
bundle whose --version pays real Python startup, and charging that to
every first Claude session per run guards only against a re-mount
between launches.

A14 — extractTarGz hardcoded '/usr/bin/tar'; on an image where that
path is absent the extraction died on a path that was wrong from the
start. resolveTarBinary() prefers the absolute system path (immune to a
poisoned PATH), falls back to bare 'tar' for spawn PATH resolution
(`-xzf -C` is BSD/GNU-portable), cached per process. The ps half of A14
needs NO change: processLock.ts already uses PATH-resolved ps with
portable `-o command=` syntax and degrades to an inconclusive grace
window — verified, not re-touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
formatWorktreeDump produces text that gets SAVED and PASTED across
machines (it feeds formatWorktreeDumpPrompt, whose whole purpose is
handing the dump to another agent, possibly on another box). Its two
absolute timestamps used toLocaleString(), whose output depends on the
viewing machine's locale AND timezone — so byte-identical worktree
states produced diffing dumps, and a timestamp like "7/9/2026, 3:04 PM"
carried no timezone for the reader on the other end to anchor it.

Both absolute timestamps (Generated + Activity index updated) now use
toISOString(): stable, sortable, timezone-explicit (UTC). The row-level
relativeTime() usages stay — they are ephemeral UI phrasing ("2h ago"),
already locale-independent, and relative time is the right register for
a per-row freshness signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing, fast-path exec probe, disk-full errnos

Four MINOR findings from the 2-Codex review, all verified real:

1. terminalSession.ts (A12): setting LANG alone was a no-op whenever
   LC_ALL=C or LC_CTYPE=C was inherited (POSIX precedence LC_ALL >
   LC_CTYPE > LANG). C/POSIX/empty are now treated as placeholders and
   deleted from all three vars before defaulting LANG; real locale
   values (UTF-8 or not) are never touched.

2. RemoteController.ts (A11): translateBindError wrapped ALL of
   doEnable, so an EACCES from loadOrCreateRemoteSecret()/registry
   state on an unwritable ~/.config/agent-code/remote was misreported
   as a firewall-blocked bind. Translation is now gated on
   syscall === 'listen' — the exact shape of the server.listen()
   'error'-event rejection both transports produce.

3. runtimeTools.ts (A13): the mitmdump .installed fast path returned
   after access(X_OK) only, so installs marked on a prior launch
   bypassed the real-exec probe and a since-turned-noexec userData
   volume got the old opaque downstream failure. The fast path now runs
   the same probe (cached per path — one spawn per app run) and
   degrades to null with the typed noexec diagnosis logged.

4. index.ts (A10): friendly startup-lock dialog now also catches
   ENOSPC/EDQUOT — the dialog copy tells users to check disk space, so
   a full disk must reach that copy instead of the raw crash.

Gates: tsc node + web green; vitest 474 passed (hotkeyBinding suite
failure pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 71d059d into main Jul 9, 2026
@Juliusolsson05
Juliusolsson05 deleted the fix/runtime-robustness-tail branch July 9, 2026 09:55
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