Skip to content

fix(setup): layered binary resolution + manual override — kill the A1 first-launch lockout (#495) - #504

Merged
Juliusolsson05 merged 4 commits into
mainfrom
fix/setup-resolver-lockout
Jul 9, 2026
Merged

Juliusolsson05 merged 4 commits into
mainfrom
fix/setup-resolver-lockout

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

What

Part 1 of the #495 Tier-A portability plan (docs/superpowers/plans/2026-07-09-portability-tier-a-any-mac-hardening.md on the plan/portability-495-496 branch): finding A1, the only CRITICAL — a user with fish/nushell as $SHELL (or a PATH added by a non-login rc block) got every required tool falsely reported missing, and SetupGate rendered with no Continue button and no way through. Total first-launch lockout on a correctly-provisioned Mac.

Three commits, three layers — a false negative now has to get through all of them:

  1. POSIX-guarded probe shell (shell.ts): runLoginShell uses $SHELL only when its basename is a known POSIX shell (bash/zsh/sh/dash/ksh); otherwise it probes through the first of /bin/zsh → /bin/bash → /bin/sh that exists, picked per call. No per-shell dialect handling on purpose — fish-only PATH additions are layer 2's job.
  2. Direct PATH/well-known-dir scan (binaryResolver.ts): both the empty-result and thrown-probe cases now fall through to an X_OK scan of process.env.PATH plus standard install dirs (~/.local/bin, volta/asdf/bun/cargo shims, brew prefixes, /usr/bin, /bin). The login-shell probe stays first so a version manager's shim keeps beating a stale system copy.
  3. Manual path override (setup:set-tool-path IPC + SetupGate UI): missing required rows get an "Enter path manually…" input. Validates absolute + executable, persists through the same updateToolPaths() the probe uses, returns a fresh check so the gate unlocks in one round-trip.

Load-bearing companion fix

checkPrerequisites's write-back deleted persisted tool paths whenever a probe returned null — it would have erased the manual override seconds after saving it, and was already silently wiping good cached paths on any transient probe failure. The probe now falls back to the persisted path when that file still execs; genuinely-removed tools still clear because the fallback re-checks the file, not the bookkeeping.

Verification

  • npx tsc -b tsconfig.node.json ✅ and npx tsc --noEmit -p tsconfig.web.json ✅
  • Throwaway vitest spec (run, then deleted — not committed): runLoginShell succeeds with SHELL=/usr/local/bin/fish; resolveToolPath('git') returns an absolute path with SHELL=/nonexistent/bin/bash (probe ENOENT → scan fallback) ✅

Refs #495 (A1). Parts 2–4 of the plan follow as separate PRs.

🤖 Generated with Claude Code

Juliusolsson05 and others added 3 commits July 9, 2026 10:11
…fish/nushell

runLoginShell used to exec $SHELL with a hardcoded ['-lc', script]. With
fish/nushell/xonsh/elvish/pwsh as the login shell that probe fails (flag
grammar / command -v semantics differ), resolveToolPath mapped the failure
to null, checkPrerequisites marked required providers found:false, and
SetupGate rendered with no Continue button — a total first-launch lockout
on a correctly-provisioned machine (#495 A1).

Now the probe shell is picked per call: $SHELL when its basename is a
known POSIX shell, else the first of /bin/zsh → /bin/bash → /bin/sh that
exists. Non-POSIX users' shell-private PATH additions are covered by the
direct PATH scan added to binaryResolver in the next commit, not by
teaching this file per-shell dialects.

Refs #495 (A1, 1/3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hell probe fails

resolveToolPath treated any login-shell probe failure as "tool missing"
(bare catch → null). That made the probe a single point of failure for the
whole product: checkPrerequisites gates required providers on it, and a
false negative locks SetupGate (#495 A1).

The probe stays layer 1 — its answer reflects the user's curated login
environment, so a version manager's shim must keep beating a stale system
copy. But both the empty-result and thrown cases now fall through to a
direct X_OK scan of process.env.PATH plus the standard install dirs
(~/.local/bin, volta/asdf/bun/cargo shims, brew prefixes, /usr/bin, /bin).
The scan cannot be poisoned by rc files and covers Finder launches where
launchd's minimal PATH is all we inherit.

Refs #495 (A1, 2/3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ver lock the app

A required tool falsely reported missing used to be a dead end: SetupGate
renders no Continue button when ready:false, Retry re-runs the identical
probe, and providers are installable:false — no affordance existed to tell
the app where the binary actually is (#495 A1).

New setup:set-tool-path IPC: validates the pasted path is an absolute
executable, persists it through the same updateToolPaths() the automatic
probe uses (so revalidateToolchain and PATH augmentation treat it like any
probed path), and returns a fresh check so the gate unlocks in one
round-trip. SetupGate grows an 'Enter path manually…' disclosure on
missing required rows.

Load-bearing companion fix in checkPrerequisites: its write-back deleted
persisted paths whenever a probe returned null, which would have erased
the manual override seconds after it was saved — and was already silently
wiping previously-good cached paths on any transient probe failure. The
probe now falls back to the persisted path when that file still execs;
a genuinely-removed tool still clears because the fallback re-checks the
file, not the bookkeeping.

Refs #495 (A1, 3/3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…utables, make manual overrides win over auto-resolution

Finding 1 (BLOCKER): isExecutable() was a bare access(path, X_OK), and
POSIX X_OK on a directory means "searchable" — so /usr/local/bin passed
manual-override validation and the provider later tried to spawn a
directory. binaryResolver now exposes classifyExecutable(): stat
(following symlinks, so brew/volta symlink layouts still pass) must say
isFile() AND the file must have X_OK. isExecutable() delegates to it, so
every resolver layer (probe validation, PATH/well-known-dir scan,
persisted-path fallback) inherits the file check; setup:set-tool-path
uses the verdict directly and returns a distinct message for the
directory case (the natural typo: pasting the containing dir).

Finding 2 (MINOR): a valid manual override was consulted only as a
FALLBACK after the probes, so any auto layer that found a different
binary silently replaced the user's ~/bin/claude-wrapper with PATH's
/usr/local/bin/claude and the write-back erased the override. Overrides
now live in their own persisted map (manualToolPaths — durable user
intent, defaulted to {} for pre-existing setup.json files) and a
still-valid override wins over every automatic layer in BOTH
auto-resolution entry points: checkPrerequisites (precedence 0, WHY
comment documents the full 0-3 order) and revalidateToolchain (which
runs every app launch and would otherwise clobber the override one
session later). Auto layers only take over when the override's file is
missing or no longer an executable regular file; intent stays recorded
and resumes winning if the file comes back.

Refs #504, #495 (A1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 0b21bc0 into main Jul 9, 2026
@Juliusolsson05
Juliusolsson05 deleted the fix/setup-resolver-lockout 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