Skip to content

Portability: 'any Mac, any user' hardening (works-on-my-machine audit — Tier A) #495

Description

@Juliusolsson05

Why this exists

The app has only ever been run on one machine (an Apple-Silicon MacBook with a fully-provisioned dev environment). A 6-agent read-only portability sweep (2026-07-08) found that cross-user and cross-Mac-arch-at-the-tool-layer portability is already solid — no hardcoded /Users/... in shipped source, everything derives from os.homedir(), Intel tool manifests exist, ports are dynamic, atomic writes are correct. But a concentrated set of assumptions still break on a second Mac or a different macOS user — including one that can lock a correctly-installed user out of the entire app on first launch.

This issue tracks the "any Mac, any user" hardening — small, high-ROI, and worth doing regardless of whether we ever target Linux. Linux/Windows enablement is tracked separately (see the companion issue linked below).

Full audit writeup lives at temp/portability-audit-issue.md in-repo.


Findings

A1 · CRITICAL — Login-shell binary resolution can falsely report a tool missing and hard-lock the user out of the entire app

src/main/setup/shell.ts:16 · src/main/setup/binaryResolver.ts:33-45 · gate at src/renderer/.../SetupGate.tsx:52

Every required tool (claude, codex, opencode, git, mitmdump) is located by running runLoginShell('command -v <tool>'), which execs process.env.SHELL || '/bin/zsh' with ['-lc', script]. If the user's $SHELL is fish / nushell / xonsh (which don't accept the combined -lc bash/zsh form) or their claude is on a PATH added by a non-login rc block, command -v returns empty → the resolver returns nullcheckPrerequisites marks the required provider found:falseSetupGate renders with no "Continue" button and blocks the whole product — even though the tool is installed and on the user's real interactive PATH.

Fix: when the login-shell probe returns nothing, fall back to a direct process.env.PATH scan (split(':') + access(X_OK)); detect non-POSIX $SHELL and use a known POSIX shell for the probe; never let a resolver false-negative be the sole gate.

A2 · HIGH — macOS build is single-arch (host only); an Apple-Silicon build gives Intel-Mac users no runnable artifact

electron-builder.yml:32-35 (no arch: field) · CI runs macos-latest (arm64)

electron-builder defaults to the host arch, so CI produces an arm64-only dmg/zip. artifactName even templates ${arch}, implying multi-arch was intended but never configured. An Intel-Mac colleague cannot run the shipped build.

Fix: mac.target: { target: dmg, arch: [x64, arm64] } (or universal). The bundled-tool cache already stages both darwin arches, so extraResources are ready. (Depends on A3.)

A3 · HIGH — node-pty is rebuilt for the host arch and its .node isn't explicitly in asarUnpack — packaged-only PTY breakage

package.json postinstall: electron-rebuild -f -w node-pty · electron-builder.yml:7-13

node-pty is the PTY layer for every terminal. Two hazards: (a) electron-rebuild builds for the host arch, so a cross-arch dmg (A2) ships a pty.node for the wrong CPU → crash-on-launch; (b) pty.node relies solely on electron-builder's implicit native-module detection to be unpacked from the asar — a known footgun when a custom asarUnpack list already exists. If detection misses it, the packaged app can't dlopen it → every terminal dead (invisible in npm run dev).

Fix: rebuild node-pty per target arch (@electron/rebuild --arch in a beforeBuild hook), and add **/*.node (or node_modules/node-pty/**) to asarUnpack explicitly. Verify in an actual packaged build.

A4 · HIGH — Dictation hotkey compiles Swift on the end-user's machine via xcrun swiftc (needs Xcode Command Line Tools)

src/main/dictation/macHotkeyHelper.ts:88-113

The global dictation hotkey helper ships as main.swift source and is compiled at first run via /usr/bin/xcrun swiftc. A packaged app on a Mac without Command Line Tools can't compile it → dictation hotkey silently fails (degrades to return false, failure only in a rejected promise).

Fix: pre-compile + codesign the helper at build time and bundle it as a third_party-style artifact; stop compiling on the user's machine. If compilation must stay, detect missing CLT and show an actionable message.

A5 · MEDIUM — git failures are swallowed to '' → UI shows plausible-but-false "clean / 0 ahead / no worktrees"

src/main/ipc/git.ts:115-118 · src/main/aiWorkspace/AiWorkspaceRegistry.ts:66-72

git is required:false, but every git call launders ENOENT/errors into an empty string, so the Git Bar, worktree badges, and ahead/behind counts render false state instead of surfacing "git unavailable."

Fix: distinguish ENOENT from command failure; surface a one-time "git not found — Git features disabled" banner.

A6 · MEDIUM — Unsigned-dev-build fallback lives only in CI; a fresh contributor's npm run dist:mac fails without Apple certs

electron-builder.yml:36-38 · fallback CSC_IDENTITY_AUTO_DISCOVERY=false exists only in .github/workflows/release.yml

The npm scripts don't set the auto-discovery-off env var, so a contributor without Developer-ID certs gets a failed/broken signature locally.

Fix: move the CSC_IDENTITY_AUTO_DISCOVERY=false fallback into an npm wrapper so unsigned dev builds work everywhere.

A7 · MEDIUM — getFreePort() → mitmdump has a TOCTOU race with no EADDRINUSE retry

packages/claude-code-headless/src/proxy/proxyServer.ts:652-671 (consumed :434, handed to mitmdump --listen-port :173)

The port is chosen by bind-then-close a throwaway net.createServer(), then handed to mitmdump moments later. On a busy machine another process can grab it in the gap → mitmdump exits EADDRINUSE → the proxied provider session (spine of every Claude/Codex run) fails to start.

Fix: hand mitmdump --listen-port 0 and parse the actually-bound port (Codex's responsesProxy.ts:415 already does listen(0)), or retry on EADDRINUSE.

A8 · MEDIUM — Shell selection has no existence check and a documented fallback chain that isn't implemented

src/shared/runtime/terminalSession.ts:119-122 · src/main/sessionManager.ts:650

this.shell = options.shell ?? process.env.SHELL ?? '/bin/zsh'. The doc comment claims "$SHELL → /bin/zsh → /bin/bash → sh," but no bash/sh fallback and no existence probe are implemented. Where $SHELL is unset and zsh isn't installed, every terminal/tmux session dies at spawn.

Fix: actually probe $SHELL, /bin/zsh, /bin/bash, /bin/sh with access(X_OK) and pick the first that exists; log the choice.

A9 · MEDIUM — Bare-name provider spawn fallback + asymmetric spawn-error handling (Claude vs Codex)

src/main/setup/toolchain.ts:119 · src/providers/claude/runtime/claudeSession.ts:337,345 vs src/providers/codex/runtime/codexSession.ts:295-305

When the toolchain cache is empty, getToolPath(kind, kind) returns the bare literal 'claude'/'codex', relying on the runtime-augmented PATH; a binary in ~/.local/bin / an asdf/Volta shim won't be found. And codexSession wraps spawn in try/catch with rollbackStart() while claudeSession does not — divergent behavior on a missing binary.

Fix: treat "no cached absolute path" as not ready rather than optimistically spawning a bare name; unify spawn-error handling across providers.

A10 · MEDIUM — App assumes a writable, roomy ~/.config/agent-code; debug roots never rotate

src/main/storage/paths.ts:18 + every writer under it (feed-debug, debug-bundles, proxy, performance, incidents, heap-snapshots, session-recordings)

On a quota'd / read-only / tmpfs home the always-on incident/heartbeat writers throw on the hot path; on a roomy-but-busy machine the never-rotating debug dirs eventually fill the disk (already a logged OOM/disk vector).

Fix: fail-soft on state-dir write errors (currently only partial coverage); enforce the budgeted retention sweep on every debug root at startup; honor $XDG_STATE_HOME/$XDG_CACHE_HOME for cache-like forensics.

A11 · MEDIUM — LAN pairing binds 0.0.0.0; firewall prompt/block leaves the phone unable to connect with no desktop-side error

src/main/remote/transport/LanTransport.ts:79

macOS application firewall fires an "accept incoming connections?" prompt (or silently blocks an unsigned app). The QR shows a reachable-looking URL the phone can never reach, with nothing surfaced on the desktop.

Fix: detect bind/firewall rejection and surface it; consider auto-falling-back to the tunnel transport when the LAN bind is unreachable.

A12–A18 · LOW (Mac-relevant tail)

  • A12 — PTY inherits ambient locale; no LANG/LC_* UTF-8 guarantee (terminalSession.ts:143-151). Fix: default LANG=…UTF-8 when inherited locale is unset/non-UTF-8.
  • A13 — Downloaded binaries assume an exec-permitted mount; on a noexec home/tmp, chmod 0o755 succeeds but spawn EACCESs (fetch-*.mjs, runtimeTools.ts). Fix: probe with a real --version exec; surface a noexec diagnostic.
  • A14/usr/bin/tar (runtimeTools.ts:495, extraction spine for every bundled tool) and BSD-ps (processLock.ts:62) are hardcoded/un-guarded. Fix: resolve from PATH.
  • A15toLocaleString()/toLocaleTimeString() in persisted worktree dumps (formatWorktreeDump.ts:11,36) → non-reproducible artifacts across machines/timezones. Fix: ISO-8601/UTC in persisted output.
  • A16 — No .nvmrc / engines floor → Node/Electron ABI drift silently breaks the node-pty rebuild. Fix: add .nvmrc (20) + engines.node.
  • A17 — Build needs 6 git submodules (4 private, on Juliusolsson05); a clone without --recurse-submodules or without SUBMODULE_PAT can't even npm run dev. Fix: document the submodule auth requirement; consider publishing/making public.
  • A18 — No .icns / icon: key → relies on macOS sips/iconutil at build. Fix: commit a prebuilt .icns.

Suggested execution order

  1. A1 first, alone — it's a total-lockout bug a fish user hits on launch.
  2. Batch the packaged-Mac set: A2 + A3 + A6 + A16 + A18 (one "cross-Mac build works" PR).
  3. A4 (bundle the Swift helper) + A5 (git-absence banner) + A8/A9 (shell/spawn robustness).
  4. A7 + A10 + A11 + A12–A15 — runtime-robustness tail.

Out of scope

  • Linux/Windows enablement — see the companion issue.
  • packages/agent-voice-dictation/apps/flow-electron/** is a separate bundled sub-app with its own darwin-gated logic; audit separately only if it ships.
  • The Google-Fonts @import (offline/CSP) is a distinct concern from OS portability.

🤖 Drafted from a 6-agent portability sweep; see temp/portability-audit-issue.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions