Skip to content

fix(auth-status): route claude auth status/login through resolver, not PATH#492

Merged
rynfar merged 1 commit into
mainfrom
fix/auth-status-resolver
May 6, 2026
Merged

fix(auth-status): route claude auth status/login through resolver, not PATH#492
rynfar merged 1 commit into
mainfrom
fix/auth-status-resolver

Conversation

@rynfar
Copy link
Copy Markdown
Owner

@rynfar rynfar commented May 6, 2026

Closes #478.

TL;DR: Stefan was right — /health showed the resolver picking the right binary, but five other code paths still spawned `claude` via shell PATH. With no `claude` globally installed (his case under `bunx` + systemd), every one of those paths failed silently. Routing all of them through the resolver fixes #478. Verified end-to-end with stripped PATH.

What @stefanpartheym revealed

His update on the issue (after I shipped #485) cut through the noise:

  1. `/health.claudeExecutable` showed `source: "platform-package"` with a clean `/tmp/bunx-1000-...`/claude path. So the resolver works.
  2. He doesn't have `claude` installed globally — he avoids it.
  3. He pointed at two specific call sites I missed: `models.ts:234` and `profileCli.ts:47`. Both spawn `claude` via shell.
  4. His warning at boot: `⚠ Could not verify Claude auth status`.

The original `error: Script not found "stream-json"` from his first report was a downstream symptom of this same class of issue — without auth-status visibility, the proxy's behavior under the missing-`claude` condition cascades in surprising ways.

What I found

Five call sites, not two:

File / line Spawns Now uses
`bin/cli.ts:157` `exec("claude auth status")` (boot pre-flight) resolved path + `execFile`
`src/proxy/models.ts:234` `exec("claude auth status")` resolved path + `execFile`
`src/proxy/profileCli.ts:47` `execSync("claude auth status")` resolved path + `execFileSync`
`src/proxy/profileCli.ts:136` `spawnSync("claude", ["auth","login"])` resolved path + `spawnSync`
`src/proxy/profileCli.ts:306` `spawnSync("claude", ["auth","login"])` resolved path + `spawnSync`

Fix

  1. New `resolveClaudeExecutableSync` in `src/proxy/models.ts` — sync subset of the resolver. Steps: env → bundled → platform-package. Skips path-lookup (sync exec is platform-fragile) and legacy-cli-js (only matters on stale Bun installs of SDK < 0.2.98). Returns `{ path, source } | null`.

  2. All five call sites refactored to resolve the path first, then spawn via that explicit path. `execFile` / `execFileSync` (not `exec` / `execSync`) so we bypass the shell entirely — no PATH lookup, no quoting issues with spaces in resolved paths.

  3. `runCli` test injection point updated from `runExec` (typeof exec) to `runAuthCheck` (`() => Promise<{stdout}>`). The existing "keeps CLI missing-binary warning behavior" test still validates the same scenario (auth check fails → warning fires) under the new contract.

Tests

5 new in `src/tests/claude-executable-resolver.test.ts`:

  • Sync resolver returns `{path, source: "env"}` when MERIDIAN_CLAUDE_PATH wins
  • ...returns `{..., source: "bundled"}` when bundled binary is real
  • ...returns `{..., source: "platform-package"}` after bundled stub
  • ...returns null when env/bundled/platform-pkg all miss (PATH lookup intentionally skipped)
  • ...does NOT consult `exec` (purely synchronous deps — pin the contract: pass an exec that throws and assert resolution still works)

1 existing test in `proxy-async-ops.test.ts` updated for the new `runCli` signature.

Full suite: 1732 / 0. Typecheck clean. Build clean.

End-to-end verification

Reproduced Stefan's environment in miniature: booted the patched build with `PATH=/usr/local/bin:/usr/bin:/bin` (no `~/.local/bin` where my real `claude` shim lives — confirmed missing via `PATH=... which claude` → not found).

Before this PR (current main):
```
⚠ Could not verify Claude auth status. If requests fail, run: claude login
```

After this PR (this branch):
```
[PROXY] Plugins loaded: 2 active, 0 disabled, 0 errors
Meridian running at http://127.0.0.1:3500
Telemetry dashboard: http://127.0.0.1:3500/telemetry
Model pins: opus=claude-opus-4-7 sonnet=claude-sonnet-4-6 haiku=claude-haiku-4-5
Claude executable: /Users/.../node_modules/@anthropic-ai/claude-code/bin/claude.exe (resolved via bundled)
```

```json
{
"status": "healthy",
"auth": {
"loggedIn": true,
"email": "...",
"subscriptionType": "max"
},
"claudeExecutable": {
"path": "/Users/.../node_modules/@anthropic-ai/claude-code/bin/claude.exe",
"source": "bundled"
}
}
```

No warning. Auth verified. `/health` reports the right state. Same image-binary resolution path that was working for the SDK subprocess all along — now applied consistently across every call site.

Why a sync resolver

`profileCli.ts:getAuthStatus` is sync, called from `profileList` / `profileAdd` / `profileLogin` — making it async ripples through the entire CLI. A small sync resolver covering the FS-only steps (env / bundled / platform-package) is the surgical fix. Path-lookup is intentionally skipped — sync `execSync("which claude")` is platform-fragile and the audit showed bundled + platform-package covers every supported install layout (npm-global, npx/bunx, Docker, NixOS).

Risk

Strictly improves on existing behavior:

  • If `claude` was on PATH before AND the resolver also resolves it → no change (resolved path is preferred).
  • If `claude` was on PATH but resolver resolves a different binary → resolver wins. This is consistent with how the SDK subprocess already behaves (and is the whole reason `MERIDIAN_CLAUDE_PATH` exists as an escape hatch).
  • If `claude` was NOT on PATH and the resolver resolves correctly → fixes After update to v1.41.0 getting error: Script not found "stream-json" #478.
  • If `claude` was NOT on PATH and the resolver also fails → user sees a clearer error message ("Could not resolve a Claude executable for auth check") instead of a confusing "spawn ENOENT".

Merge strategy

Squash merge. Hold on merging — Stefan should verify with the next release, but this one's small and well-tested enough that you can ship it whenever.

Credit

Diagnosis, the two-call-site pointer, and the workaround that confirmed the hypothesis: @stefanpartheym in #478. The other three call sites surfaced during the audit while implementing his pointer.

…t PATH (#478)

@stefanpartheym diagnosed that two call sites in v1.42.0 still spawn
`claude` via shell PATH instead of routing through the resolver we
shipped in #485. He's correct — and there's a third site he didn't see
plus two related `claude auth login` invocations that share the same
flaw. Five total.

Repro: bunx-installed meridian under systemd with no global `claude`
binary. The resolver correctly picks the bundled or platform-package
path for the SDK subprocess (visible in /health.claudeExecutable), but
the auth-status / auth-login spawns don't go through it — they hit
shell PATH. Without `claude` on PATH, those spawns fail and the user
sees "Could not verify Claude auth status" on startup, plus
`meridian profile list` and `meridian profile add` silently fall back
to "loggedIn: false" reporting.

Fixes:

1. New `resolveClaudeExecutableSync` in src/proxy/models.ts. Sync
   subset of the resolver — env override, bundled, platform-package.
   Skips path-lookup (sync exec is platform-fragile) and
   legacy-cli-js (only matters for stale Bun installs of SDK < 0.2.98).
   Used by sync CLI commands that can't await before spawning claude.

2. `getClaudeAuthStatusAsync` (models.ts:234) — now resolves the
   executable path via `resolveClaudeExecutableAsync` and runs
   `<resolved> auth status` via `execFile`. No PATH dependency.

3. `getAuthStatus` (profileCli.ts:47) — sync version using
   `resolveClaudeExecutableSync` + `execFileSync`. Powers
   `meridian profile list` reporting.

4. `runCli`'s pre-flight auth check (bin/cli.ts:157) — same fix.
   Refactored the test injection point: was `runExec` (typeof exec),
   now `runAuthCheck` (() => Promise<{stdout}>). The existing test
   that simulates spawn ENOENT keeps working with the new contract.

5. `profileAdd` and `profileLogin` — both spawn `claude auth login`
   for the browser handshake. Same fix: resolve the path first, then
   spawn via the resolved binary.

Tests: 5 new in claude-executable-resolver.test.ts covering the sync
resolver (env / bundled / platform-package / null-on-miss / does-not-
consult-exec). 1 existing test in proxy-async-ops.test.ts updated for
the runCli signature change. Full suite 1732/0.

End-to-end verification: booted the patched build with PATH stripped
of any `claude` shim (PATH=/usr/local/bin:/usr/bin:/bin — no
~/.local/bin). Result:

  - No "Could not verify Claude auth status" warning at startup ✓
  - /health reports loggedIn:true with email/subscriptionType ✓
  - claudeExecutable.source resolves correctly via bundled ✓

Stefan should be unblocked once 1.42.1 (or whatever version this lands
in) reaches him.
@rynfar rynfar merged commit 5ca8212 into main May 6, 2026
3 checks passed
@rynfar rynfar deleted the fix/auth-status-resolver branch May 6, 2026 17:34
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.

After update to v1.41.0 getting error: Script not found "stream-json"

1 participant