diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f815ac75be34..cf24c12716f8 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,6 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -31,6 +32,7 @@ import { import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, DEFAULT_TIMEOUT_MS, isCommandMissingCause, @@ -40,7 +42,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeEnvironment, resolveClaudeHomePath } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -798,6 +800,120 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); +/** + * Shape of `claude auth status --json`. Only `loggedIn` is load-bearing; the + * rest is used to label the account when the CLI knows more than the SDK's + * initialization result does. + */ +type ClaudeAuthStatusProbe = { + readonly loggedIn: boolean; + readonly authMethod: string | undefined; + readonly apiProvider: string | undefined; + readonly email: string | undefined; + readonly subscriptionType: string | undefined; +}; + +function parseClaudeAuthStatus(stdout: string): ClaudeAuthStatusProbe | undefined { + const trimmed = stdout.trim(); + if (!trimmed.startsWith("{")) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + if (typeof record["loggedIn"] !== "boolean") return undefined; + const readString = (key: string): string | undefined => + typeof record[key] === "string" ? nonEmptyProbeString(record[key]) : undefined; + return { + loggedIn: record["loggedIn"], + authMethod: readString("authMethod"), + apiProvider: readString("apiProvider"), + email: readString("email"), + subscriptionType: readString("subscriptionType"), + }; +} + +/** + * Ask the CLI itself whether this instance has credentials. + * + * The SDK initialization probe cannot answer this: a logged-out config + * directory still initializes fine and reports + * `{ tokenSource: "none", apiProvider: "firstParty" }`, which the snapshot used + * to read as "authenticated". That mattered little when every install had one + * Claude, but a second instance points `CLAUDE_CONFIG_DIR` at a fresh directory + * that is logged out by construction — so the provider list claimed + * "Authenticated" while every turn died with "Not logged in". + * + * Returns `undefined` when the command is missing or unparseable (older CLIs + * predate `claude auth status`), which leaves the previous behaviour intact. + */ +const probeClaudeAuthStatus = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, +): Effect.Effect< + ClaudeAuthStatusProbe | undefined, + never, + ChildProcessSpawner.ChildProcessSpawner | Path.Path +> => + Effect.gen(function* () { + // `claude auth status` emits JSON by default; the flag is not passed so the + // command still works on CLI versions that predate `--json`. + const probe = yield* runClaudeCommand(claudeSettings, ["auth", "status"], environment).pipe( + // The auth budget, not the generic one: a first run can be slow, and a + // timeout here degrades to the SDK's verdict — which is the false + // "authenticated" this probe exists to correct. + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(probe) || Option.isNone(probe.success)) return undefined; + return parseClaudeAuthStatus(probe.success.value.stdout); + }).pipe( + // A CLI that cannot answer must never take the health check down with it: + // an unknown answer keeps the previous SDK-derived verdict. + Effect.catchCause(() => Effect.succeed(undefined)), + ); + +/** + * The exact command that logs *this* instance in. Instances with a custom + * `homePath` need the `CLAUDE_CONFIG_DIR` prefix, otherwise the user logs the + * default instance in again and the failing one stays broken. + */ +/** + * Quote a path for the shell the hint will be pasted into. Home directories + * routinely contain spaces ("C:\\Users\\John Doe"), and an unquoted path + * produces a command that cannot run. + */ +function quoteForShell(value: string, platform: NodeJS.Platform): string { + return platform === "win32" + ? `'${value.replaceAll("'", "''")}'` + : `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** + * The exact command that logs *this* instance in. Instances with a custom + * `homePath` need the `CLAUDE_CONFIG_DIR` prefix, otherwise the user logs the + * default instance in again and the failing one stays broken. + */ +const claudeLoginHint = Effect.fn("claudeLoginHint")(function* ( + claudeSettings: ClaudeSettings, +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (claudeSettings.homePath.trim().length === 0) { + return "Claude Code is not authenticated. Run `claude auth login` and try again."; + } + const resolvedHomePath = yield* resolveClaudeHomePath(claudeSettings); + const quoted = quoteForShell(resolvedHomePath, platform); + // PowerShell has no inline `VAR=value command` form. + const command = + platform === "win32" + ? `$env:CLAUDE_CONFIG_DIR=${quoted}; claude auth login` + : `CLAUDE_CONFIG_DIR=${quoted} claude auth login`; + return `Claude Code is not authenticated for this instance. Run \`${command}\` and try again.`; +}); + export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( claudeSettings: ClaudeSettings, resolveCapabilities?: ( @@ -929,6 +1045,25 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(capabilities?.slashCommands ?? []), ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + const authStatus = yield* probeClaudeAuthStatus(claudeSettings, resolvedEnvironment); + + if (authStatus?.loggedIn === false) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unauthenticated" }, + message: yield* claudeLoginHint(claudeSettings), + }, + }); + } if (!capabilities) { return buildServerProvider({ @@ -948,11 +1083,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + // The CLI's own auth status is the better source for account identity: with + // several instances configured, the email is what tells two Claude accounts + // apart in the provider list. Fall back to the SDK probe when the CLI is too + // old to answer. + const authEmail = authStatus?.email ?? capabilities.email; const authMetadata = claudeAuthMetadata({ - subscriptionType: capabilities.subscriptionType, - authMethod: capabilities.tokenSource, - }) ?? apiProviderAuthMetadata(capabilities.apiProvider); + subscriptionType: authStatus?.subscriptionType ?? capabilities.subscriptionType, + authMethod: authStatus?.authMethod ?? capabilities.tokenSource, + }) ?? apiProviderAuthMetadata(authStatus?.apiProvider ?? capabilities.apiProvider); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -966,7 +1106,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: "ready", auth: { status: "authenticated", - ...(capabilities.email ? { email: capabilities.email } : {}), + ...(authEmail ? { email: authEmail } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 663ee90368b8..c565f3991e33 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -13,6 +13,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ClaudeSettings, CodexSettings, @@ -55,6 +56,14 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); +/** A second Claude instance pointed at its own CLAUDE_CONFIG_DIR. */ +const workClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({ + homePath: "/tmp/claude-work", +}); +/** Home directories with spaces are ordinary; the hint has to survive them. */ +const spacedClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({ + homePath: "/Users/John Doe/.claude-work", +}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); const decodeCodexSettings = Schema.decodeSync(CodexSettings); const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ @@ -1802,6 +1811,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + // A logged-out config directory still initializes fine through the SDK + // and reports `tokenSource: "none"`, which used to read as + // "authenticated". A second Claude instance points CLAUDE_CONFIG_DIR at a + // fresh directory, so this is the default state for every added instance + // until the user logs it in. + it.effect("reports unauthenticated when the CLI has no credentials", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "none", apiProvider: "firstParty" }), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude Code is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + // The login hint has to name the instance's own config directory, + // otherwise the user re-logs-in the default instance and the failing one + // stays broken. + it.effect("points the login hint at this instance's CLAUDE_CONFIG_DIR", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + workClaudeSettings, + claudeCapabilities({ tokenSource: "none", apiProvider: "firstParty" }), + ); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude Code is not authenticated for this instance. Run `CLAUDE_CONFIG_DIR='/tmp/claude-work' claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + // An unquoted path produces a command the shell cannot run, which makes + // the hint useless for exactly the users who need it. + it.effect("quotes a config directory containing spaces", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + spacedClaudeSettings, + claudeCapabilities({ tokenSource: "none", apiProvider: "firstParty" }), + ); + assert.strictEqual( + status.message, + "Claude Code is not authenticated for this instance. Run `CLAUDE_CONFIG_DIR='/Users/John Doe/.claude-work' claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + // PowerShell has no inline `VAR=value command` form, so the POSIX hint is + // not a command a Windows user can run. + it.effect("uses PowerShell syntax for the login hint on Windows", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + workClaudeSettings, + claudeCapabilities({ tokenSource: "none", apiProvider: "firstParty" }), + ); + assert.strictEqual( + status.message, + "Claude Code is not authenticated for this instance. Run `$env:CLAUDE_CONFIG_DIR='/tmp/claude-work'; claude auth login` and try again.", + ); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + // Older CLIs predate `claude auth status`. Losing the probe must not + // downgrade a working provider to "unauthenticated". + it.effect("keeps the SDK verdict when the CLI cannot report auth status", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "max" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "", stderr: "unknown command", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + // With two Claude accounts configured, the email is what tells the two + // instances apart in the provider list. + it.effect("labels the account from the CLI when the SDK omits it", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.email, "work@example.com"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: + '{"loggedIn":true,"authMethod":"claude.ai","email":"work@example.com","subscriptionType":"max"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => Effect.gen(function* () { // Bedrock authenticates via external AWS credentials, so the SDK init @@ -2132,9 +2317,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeCapabilities(), ); assert.strictEqual(status.status, "ready"); + // Both the version probe and the auth-status probe have to run + // against this instance's config directory — an auth check that + // leaked to the default home would report the wrong account. assert.deepStrictEqual( recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [claudeConfigDir], + [claudeConfigDir, claudeConfigDir], ); }).pipe(Effect.provide(recorded.layer)); }); @@ -2315,9 +2503,34 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + // Neither probe can answer: the CLI predates `auth status` and + // the SDK returned no initialization result. + if (joined === "auth status") + return { stdout: "Usage: claude auth\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + // `claude auth status` exits non-zero *because* the user is logged out, + // while still printing a well-formed verdict on stdout. Gating the parse + // on the exit code would throw away the only reliable signal there is. + it.effect("trusts the auth-status JSON even when the command exits non-zero", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.auth.status, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.231\n", stderr: "", code: 0 }; if (joined === "auth status") return { - stdout: '{"loggedIn":false}\n', + stdout: '{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}\n', stderr: "", code: 1, };