diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 5d63060493d9..d617eb999fa6 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -279,6 +279,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth", + apiKeySource: undefined, apiProvider: undefined, slashCommands: [ { diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 01d731481c23..553d3d2e5fef 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -167,6 +167,38 @@ function apiProviderAuthMetadata( return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; } +/** + * Whether the SDK's account payload evidences a credential the CLI can use. + * + * The capability probe resolves for a logged-out CLI, so a completed probe only + * proves Claude Code started. `tokenSource: "none"` is the CLI reporting it + * found no token at all, and is the one shape that disproves authentication. + * Everything else either names a credential or, on a third-party backend, omits + * these fields by design because auth lives with AWS or gcloud instead. + * + * Silence is deliberately not disproof. Profile-authenticated installs report no + * token source, and a CLI too old to send an account payload reports nothing at + * all; treating either as logged out would sign working setups out of Settings. + * `apiKeySource: "none"` means no API key is in use, so it is no evidence either. + */ +function claudeAuthStatus( + capabilities: Pick< + ClaudeCapabilitiesProbe, + "email" | "subscriptionType" | "tokenSource" | "apiKeySource" | "apiProvider" + >, +): "authenticated" | "unauthenticated" { + if (capabilities.apiProvider !== undefined && capabilities.apiProvider !== "firstParty") { + return "authenticated"; + } + if (capabilities.tokenSource !== "none") return "authenticated"; + // An `ANTHROPIC_API_KEY` install reports no token source but is authenticated + // all the same, so the key and account fields still get a say. + const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; + return hasApiKey || capabilities.email || capabilities.subscriptionType + ? "authenticated" + : "unauthenticated"; +} + // ── SDK capability probe ──────────────────────────────────────────── // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the @@ -232,6 +264,8 @@ type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + /** Where the CLI found an API key, when it authenticates with one. */ + readonly apiKeySource: string | undefined; /** * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) @@ -387,6 +421,7 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiKeySource?: string; readonly apiProvider?: string; } | undefined; @@ -394,6 +429,7 @@ const probeClaudeCapabilities = ( email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), ...(usage ? { usage } : {}), @@ -590,6 +626,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + if (claudeAuthStatus(capabilities) === "unauthenticated") { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unauthenticated" }, + message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + }, + }); + } + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index b76e079fc38a..8653bd32ebd2 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -164,6 +164,7 @@ type TestClaudeCapabilities = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + readonly apiKeySource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial = {}) { email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiKeySource: undefined, apiProvider: undefined, slashCommands: [], ...overrides, @@ -3167,6 +3169,100 @@ it.layer( ), ); + it.effect("reports a logged-out CLI as unauthenticated", () => + Effect.gen(function* () { + // The capability probe resolves for a logged-out CLI, so `tokenSource: + // "none"` is the only thing separating it from an authenticated one. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps an API key install authenticated when it reports no token source", () => + Effect.gen(function* () { + // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that + // field alone would log the install out. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "ANTHROPIC_API_KEY", + apiProvider: "firstParty", + }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps a third-party backend authenticated without any token source", () => + Effect.gen(function* () { + // Bedrock and Vertex authenticate outside the CLI, so the account + // payload is empty by design rather than because nobody logged in. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "none", apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps a CLI that says nothing about its account authenticated", () => + Effect.gen(function* () { + // Profile-authenticated installs report no token source at all, and a + // CLI too old to send an account payload reports nothing whatsoever. + // Only `tokenSource: "none"` disproves authentication; saying nothing + // is not the same as saying no. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( @@ -3204,6 +3300,7 @@ it.layer( email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiKeySource: undefined, apiProvider: undefined, slashCommands: [], usage: { rate_limits_available: true, rate_limits: {} },