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 null → checkPrerequisites marks the required provider found:false → SetupGate 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.
- A15 —
toLocaleString()/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
- A1 first, alone — it's a total-lockout bug a
fish user hits on launch.
- Batch the packaged-Mac set: A2 + A3 + A6 + A16 + A18 (one "cross-Mac build works" PR).
- A4 (bundle the Swift helper) + A5 (git-absence banner) + A8/A9 (shell/spawn robustness).
- 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.
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 fromos.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).
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 atsrc/renderer/.../SetupGate.tsx:52Every required tool (
claude,codex,opencode,git,mitmdump) is located by runningrunLoginShell('command -v <tool>'), which execsprocess.env.SHELL || '/bin/zsh'with['-lc', script]. If the user's$SHELLis fish / nushell / xonsh (which don't accept the combined-lcbash/zsh form) or theirclaudeis on a PATH added by a non-login rc block,command -vreturns empty → the resolver returnsnull→checkPrerequisitesmarks the required providerfound:false→SetupGaterenders 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.PATHscan (split(':')+access(X_OK)); detect non-POSIX$SHELLand 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(noarch:field) · CI runsmacos-latest(arm64)electron-builder defaults to the host arch, so CI produces an arm64-only dmg/zip.
artifactNameeven 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] }(oruniversal). The bundled-tool cache already stages both darwin arches, soextraResourcesare ready. (Depends on A3.)A3 · HIGH —
node-ptyis rebuilt for the host arch and its.nodeisn't explicitly inasarUnpack— packaged-only PTY breakagepackage.jsonpostinstall: electron-rebuild -f -w node-pty·electron-builder.yml:7-13node-pty is the PTY layer for every terminal. Two hazards: (a)
electron-rebuildbuilds for the host arch, so a cross-arch dmg (A2) ships apty.nodefor the wrong CPU → crash-on-launch; (b)pty.noderelies solely on electron-builder's implicit native-module detection to be unpacked from the asar — a known footgun when a customasarUnpacklist already exists. If detection misses it, the packaged app can'tdlopenit → every terminal dead (invisible innpm run dev).Fix: rebuild node-pty per target arch (
@electron/rebuild --archin abeforeBuildhook), and add**/*.node(ornode_modules/node-pty/**) toasarUnpackexplicitly. 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-113The global dictation hotkey helper ships as
main.swiftsource 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 toreturn 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 —
gitfailures 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-72gitisrequired: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:macfails without Apple certselectron-builder.yml:36-38· fallbackCSC_IDENTITY_AUTO_DISCOVERY=falseexists only in.github/workflows/release.ymlThe 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=falsefallback into an npm wrapper so unsigned dev builds work everywhere.A7 · MEDIUM —
getFreePort()→ mitmdump has a TOCTOU race with no EADDRINUSE retrypackages/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 0and parse the actually-bound port (Codex'sresponsesProxy.ts:415already doeslisten(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:650this.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$SHELLis unset and zsh isn't installed, every terminal/tmux session dies at spawn.Fix: actually probe
$SHELL,/bin/zsh,/bin/bash,/bin/shwithaccess(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,345vssrc/providers/codex/runtime/codexSession.ts:295-305When 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. AndcodexSessionwraps spawn in try/catch withrollbackStart()whileclaudeSessiondoes 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 rotatesrc/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_HOMEfor 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 errorsrc/main/remote/transport/LanTransport.ts:79macOS 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)
LANG/LC_*UTF-8 guarantee (terminalSession.ts:143-151). Fix: defaultLANG=…UTF-8when inherited locale is unset/non-UTF-8.noexechome/tmp,chmod 0o755succeeds butspawnEACCESs (fetch-*.mjs,runtimeTools.ts). Fix: probe with a real--versionexec; surface a noexec diagnostic./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.toLocaleString()/toLocaleTimeString()in persisted worktree dumps (formatWorktreeDump.ts:11,36) → non-reproducible artifacts across machines/timezones. Fix: ISO-8601/UTC in persisted output..nvmrc/enginesfloor → Node/Electron ABI drift silently breaks the node-pty rebuild. Fix: add.nvmrc(20) +engines.node.Juliusolsson05); a clone without--recurse-submodulesor withoutSUBMODULE_PATcan't evennpm run dev. Fix: document the submodule auth requirement; consider publishing/making public..icns/icon:key → relies on macOSsips/iconutilat build. Fix: commit a prebuilt.icns.Suggested execution order
fishuser hits on launch.Out of scope
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.@import(offline/CSP) is a distinct concern from OS portability.🤖 Drafted from a 6-agent portability sweep; see
temp/portability-audit-issue.md.