From a93b1deddd5b94ab0139ebcb6ebc651968b0aaf5 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Wed, 6 May 2026 11:11:45 -0600 Subject: [PATCH] fix(auth-status): route claude auth status/login through resolver, not PATH (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 ` 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. --- bin/cli.ts | 24 ++++- .../claude-executable-resolver.test.ts | 93 ++++++++++++++++++- src/__tests__/proxy-async-ops.test.ts | 7 +- src/proxy/models.ts | 45 ++++++++- src/proxy/profileCli.ts | 37 +++++++- 5 files changed, 193 insertions(+), 13 deletions(-) diff --git a/bin/cli.ts b/bin/cli.ts index 78fd248b..ccd97034 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -2,8 +2,9 @@ import { createRequire } from "module" import { startProxyServer } from "../src/proxy/server" -import { exec as execCallback } from "child_process" +import { exec as execCallback, execFile as execFileCallback } from "child_process" import { promisify } from "util" +import { resolveClaudeExecutableAsync } from "../src/proxy/models" const require = createRequire(import.meta.url) const { version } = require("../package.json") @@ -99,6 +100,7 @@ if (args[0] === "refresh-token") { } const exec = promisify(execCallback) +const execFile = promisify(execFileCallback) // Prevent SDK subprocess crashes from killing the proxy process.on("uncaughtException", (err) => { @@ -134,9 +136,22 @@ try { console.error(`[meridian] Failed to parse MERIDIAN_PROFILES: ${e instanceof Error ? e.message : e}`) } +/** + * Run the CLI default action (start the proxy server). + * + * @param start Server bootstrap (overridable for tests). + * @param runAuthCheck Pre-flight `claude auth status` runner. Default + * resolves the bundled/platform-package binary via + * `resolveClaudeExecutableAsync` and runs ` auth status` via + * `execFile` — does NOT depend on `claude` being on PATH (#478). Tests + * override this to simulate ENOENT / non-zero exit / malformed JSON. + */ export async function runCli( start = startProxyServer, - runExec: typeof exec = exec + runAuthCheck: () => Promise<{ stdout: string }> = async () => { + const claudePath = await resolveClaudeExecutableAsync() + return execFile(claudePath, ["auth", "status"], { timeout: 5000 }) + } ) { // Plugin check — warn if OpenCode config exists but meridian plugin is missing try { @@ -152,9 +167,10 @@ export async function runCli( } } catch { /* non-fatal */ } - // Pre-flight auth check + // Pre-flight auth check — runs the resolved Claude binary's auth-status + // subcommand. Independent of whether `claude` is on PATH (#478). try { - const { stdout } = await runExec("claude auth status", { timeout: 5000 }) + const { stdout } = await runAuthCheck() const auth = JSON.parse(stdout) if (!auth.loggedIn) { console.error("\x1b[31m✗ Not logged in to Claude.\x1b[0m Run: claude login") diff --git a/src/__tests__/claude-executable-resolver.test.ts b/src/__tests__/claude-executable-resolver.test.ts index 5d3076da..0eb81fb3 100644 --- a/src/__tests__/claude-executable-resolver.test.ts +++ b/src/__tests__/claude-executable-resolver.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect } from "bun:test" import { join, dirname } from "path" -import { resolveClaudeExecutable, resolveClaudeExecutableWithSource } from "../proxy/models" +import { resolveClaudeExecutable, resolveClaudeExecutableWithSource, resolveClaudeExecutableSync } from "../proxy/models" // `path.join` produces backslashed paths on Windows and slash-separated paths // on POSIX. Tests use `J(...)` and `BIN(pkgJson, ...rest)` everywhere a path @@ -485,3 +485,94 @@ describe("resolveClaudeExecutableWithSource", () => { expect(await resolveClaudeExecutableWithSource(deps)).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// resolveClaudeExecutableSync — synchronous subset used by CLI commands +// (`meridian profile list`, etc.) that can't await. Skips the async PATH +// lookup and the legacy SDK cli.js fallback. Closes the diagnostic gap +// from #478 where Stefan's auth-status checks failed because they spawned +// `claude` via shell PATH instead of routing through the resolver. +// --------------------------------------------------------------------------- + +describe("resolveClaudeExecutableSync", () => { + it("reports source 'env' when MERIDIAN_CLAUDE_PATH wins", () => { + const deps = makeDeps({ + envGet: (n) => (n === "MERIDIAN_CLAUDE_PATH" ? "/custom/claude" : undefined), + existsSync: (p) => p === "/custom/claude", + }) + expect(resolveClaudeExecutableSync(deps)).toEqual({ + path: "/custom/claude", + source: "env", + }) + }) + + it("reports source 'bundled' when claude-code/bin/claude.exe wins", () => { + const pkgJson = "/m/cc/package.json" + const expectedBin = BIN(pkgJson, "bin", "claude.exe") + const deps = makeDeps({ + resolvePackage: (s) => { + if (s === "@anthropic-ai/claude-code/package.json") return pkgJson + throw new Error("not configured") + }, + existsSync: (p) => p === expectedBin, + statSync: () => ({ size: 200_000_000 }), + }) + expect(resolveClaudeExecutableSync(deps)).toEqual({ + path: expectedBin, + source: "bundled", + }) + }) + + it("reports source 'platform-package' when peer pkg wins after bundled stub", () => { + const platformPkg = "/m/cc-d-a/package.json" + const platformBin = BIN(platformPkg, "claude") + const deps = makeDeps({ + platform: "darwin", + arch: "arm64", + resolvePackage: (s) => { + if (s === "@anthropic-ai/claude-code-darwin-arm64/package.json") return platformPkg + throw new Error("not configured") + }, + existsSync: (p) => p === platformBin, + }) + expect(resolveClaudeExecutableSync(deps)).toEqual({ + path: platformBin, + source: "platform-package", + }) + }) + + it("returns null when env, bundled, and platform-pkg all miss (PATH lookup not attempted)", () => { + // Stefan's case: no MERIDIAN_CLAUDE_PATH, no bundled binary in this + // test, no platform peer package, no `claude` on PATH. The async + // resolver's PATH-lookup step is intentionally skipped here — sync + // exec of `which`/`where` is platform-fragile, and the audit showed + // bundled/platform-pkg covers every supported install layout. + const deps = makeDeps({ + envGet: () => undefined, + resolvePackage: () => { throw new Error("nope") }, + existsSync: () => false, + }) + expect(resolveClaudeExecutableSync(deps)).toBeNull() + }) + + it("does NOT consult exec/PATH (purely synchronous deps)", () => { + // The sync resolver should not even *try* to call exec — that's the + // whole reason it exists. Pin the contract: pass an exec that throws + // and assert resolution still works via bundled. + const pkgJson = "/m/cc/package.json" + const expectedBin = BIN(pkgJson, "bin", "claude.exe") + const deps = makeDeps({ + resolvePackage: (s) => { + if (s === "@anthropic-ai/claude-code/package.json") return pkgJson + throw new Error("not configured") + }, + existsSync: (p) => p === expectedBin, + statSync: () => ({ size: 200_000_000 }), + exec: async () => { throw new Error("sync resolver must not call exec") }, + }) + expect(resolveClaudeExecutableSync(deps)).toEqual({ + path: expectedBin, + source: "bundled", + }) + }) +}) diff --git a/src/__tests__/proxy-async-ops.test.ts b/src/__tests__/proxy-async-ops.test.ts index 5ae371ad..ff202b29 100644 --- a/src/__tests__/proxy-async-ops.test.ts +++ b/src/__tests__/proxy-async-ops.test.ts @@ -82,7 +82,12 @@ describe("proxy async ops", () => { const { EventEmitter } = await import("events") return { server: new EventEmitter(), config: {}, close: async () => {} } as any }, - (() => { + // Simulate the auth-status check throwing — same scenario as before + // (binary missing / spawn error). The injection point moved from + // `runExec` to `runAuthCheck` after #478, but the assertion is the + // same: when the check fails, the warning fires and the proxy still + // starts. Test name kept stable. + (async () => { throw new Error("spawn ENOENT") }) as any ) diff --git a/src/proxy/models.ts b/src/proxy/models.ts index a18123e8..c2c594bb 100644 --- a/src/proxy/models.ts +++ b/src/proxy/models.ts @@ -2,13 +2,14 @@ * Model mapping and Claude executable resolution. */ -import { exec as execCallback } from "child_process" +import { exec as execCallback, execFile as execFileCallback } from "child_process" import { existsSync, statSync } from "fs" import { fileURLToPath } from "url" import { join, dirname } from "path" import { promisify } from "util" const exec = promisify(execCallback) +const execFile = promisify(execFileCallback) /** * Files smaller than this are treated as the placeholder stub that @@ -231,7 +232,17 @@ export async function getClaudeAuthStatusAsync(profileId?: string, envOverrides? c_promise = (async () => { try { - const { stdout } = await exec("claude auth status", { + // Route through the resolver instead of relying on `claude` being + // on PATH. Stefan's case (#478): bunx-installed meridian under + // systemd, no global claude binary — `exec("claude auth status")` + // fails before we ever spawn the SDK subprocess. The resolved + // executable comes from the same lookup chain that powers the SDK + // call (env > bundled > platform-package > PATH > legacy-cli-js), + // so this path works in every install layout the SDK already + // supports. execFile (vs exec) avoids any quoting issues with + // spaces in the resolved path. + const claudePath = await resolveClaudeExecutableAsync() + const { stdout } = await execFile(claudePath, ["auth", "status"], { timeout: 5000, ...(envOverrides ? { env: { ...process.env, ...envOverrides } } : {}), }) @@ -476,6 +487,36 @@ export async function resolveClaudeExecutable(deps: ResolverDeps = DEFAULT_DEPS) return info?.path ?? null } +/** + * Synchronous subset of the resolver. Used by CLI commands + * (`meridian profile list`, `profileAdd`, etc.) that can't await before + * spawning `claude auth status`. + * + * Skips two steps that the async resolver runs: + * - `path-lookup` — running `which`/`where` synchronously is awkward + * and platform-fragile; the audit showed bundled + platform-package + * covers every supported install layout (npm-global, npx/bunx + * download, Docker, NixOS). + * - `legacy-cli-js` — only matters for stale Bun installs of SDK < 0.2.98. + * + * Closes the diagnostic gap from #478: `getAuthStatus` in profileCli.ts + * and `getClaudeAuthStatusAsync` in this file previously called + * `claude auth status` via shell, which fails when `claude` isn't on + * PATH (Stefan's case — bunx-installed meridian under systemd, no + * global claude). Both call sites now route through resolved paths. + */ +export function resolveClaudeExecutableSync( + deps: ResolverDeps = DEFAULT_DEPS, +): ClaudeExecutableInfo | null { + const env = tryEnvOverride(deps) + if (env) return { path: env, source: "env" } + const bundled = tryBundledBinary(deps) + if (bundled) return { path: bundled, source: "bundled" } + const platformPkg = tryPlatformPackage(deps) + if (platformPkg) return { path: platformPkg, source: "platform-package" } + return null +} + /** * Returns the cached resolved-executable info — `null` if * `resolveClaudeExecutableAsync` hasn't run yet. Used by `/health` and the diff --git a/src/proxy/profileCli.ts b/src/proxy/profileCli.ts index 9cfdae97..4292b107 100644 --- a/src/proxy/profileCli.ts +++ b/src/proxy/profileCli.ts @@ -11,9 +11,10 @@ import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" -import { execSync, spawnSync } from "node:child_process" +import { execFileSync, spawnSync } from "node:child_process" import { homedir } from "node:os" import type { ProfileConfig } from "./profiles" +import { resolveClaudeExecutableSync } from "./models" import { setSetting } from "./settings" const PROFILES_DIR = join(homedir(), ".config", "meridian", "profiles") @@ -43,8 +44,20 @@ function saveProfileConfig(profiles: ProfileConfig[]): void { } function getAuthStatus(configDir: string): { loggedIn: boolean; email?: string; subscriptionType?: string } { + // Route through the synchronous resolver instead of relying on `claude` + // being on PATH (#478). The CLI command runs in whatever environment + // the user invokes it — under systemd or bunx-without-global-claude, + // PATH won't have a claude binary even when meridian's own bundled or + // platform-package binary is right there in node_modules. + const resolved = resolveClaudeExecutableSync() + if (!resolved) { + console.warn(`[meridian] Could not resolve a Claude executable for auth check (set MERIDIAN_CLAUDE_PATH or install @anthropic-ai/claude-code)`) + return { loggedIn: false } + } try { - const result = execSync("claude auth status", { + // execFileSync (vs execSync) avoids quoting issues with spaces in the + // resolved path and bypasses the shell entirely — no PATH lookup. + const result = execFileSync(resolved.path, ["auth", "status"], { timeout: 5000, env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, stdio: ["pipe", "pipe", "pipe"], @@ -119,8 +132,15 @@ export function profileAdd(id: string): void { console.log(" Press Ctrl+C to cancel, or wait for the browser to open...") console.log() - // Run claude auth login with the profile's config dir - const result = spawnSync("claude", ["auth", "login"], { + // Run claude auth login with the profile's config dir. Route through + // the sync resolver so we don't depend on `claude` being on PATH (#478). + const resolvedAuth = resolveClaudeExecutableSync() + if (!resolvedAuth) { + console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m") + console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude") + process.exit(1) + } + const result = spawnSync(resolvedAuth.path, ["auth", "login"], { env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, stdio: "inherit", }) @@ -290,7 +310,14 @@ export function profileLogin(id: string): void { console.log("\x1b[33m⚠ Make sure you're signed into the correct Claude account in your browser.\x1b[0m") console.log() - const result = spawnSync("claude", ["auth", "login"], { + // Route through the sync resolver — see profileAdd above (#478). + const resolvedLogin = resolveClaudeExecutableSync() + if (!resolvedLogin) { + console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m") + console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude") + process.exit(1) + } + const result = spawnSync(resolvedLogin.path, ["auth", "login"], { env: { ...process.env, CLAUDE_CONFIG_DIR: profile.claudeConfigDir }, stdio: "inherit", })