Repository navigation
fix: runtime-robustness tail — boot grace, LAN UX, PTY locale, noexec probe, ISO dumps (#495) - #507
Merged
Merged
Conversation
…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>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Part 4 of the #495 Tier-A portability plan — the runtime tail, one commit per finding:
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 owndialog.showErrorBoxnaming the directory +app.exit(1)(notapp.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.tsalready covers it (audit correction).doEnable's rethrow (original error kept ascause); 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.LANG=en_US.UTF-8when 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./usr/bin/tarhardcode replaced with a cached resolver (absolute path → PATH); bundled tmux/cloudflared/mitmdump get a cached-per-path real-exec version probe that turns theaccess(X_OK)-passes-but-execve-EACCES noexec signature into a typedBundledToolExecProbeErrornaming the mount suspicion. mitmdump probes its extraction tmp path before the atomic rename, so a refused binary never becomes the marked install.processLock.ts'spsusage 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, preservingresolveBundledTool's documented null-contract (a throw would abort boot over an optional feature).toISOString()(locale+TZ-dependent output made byte-identical states diff across machines).A7 (mitmdump port TOCTOU) rides separately in the
claude-code-headlesssubmodule: 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.jsonandnpx tsc --noEmit -p tsconfig.web.jsongreen 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