diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b8022..2a8f6ac9f19 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -126,6 +126,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/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b08b5db68ee..b3a2c60e88d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -576,6 +576,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` is only ever set when a key was actually found, so it has no + * "no key" sentinel to confuse with one. + */ +export 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. + return capabilities.apiKeySource || capabilities.email || capabilities.subscriptionType + ? "authenticated" + : "unauthenticated"; +} + // ── SDK capability probe ──────────────────────────────────────────── // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the @@ -635,6 +667,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"`) @@ -762,6 +796,7 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiKeySource?: string; readonly apiProvider?: string; } | undefined; @@ -769,6 +804,7 @@ const probeClaudeCapabilities = ( email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; @@ -948,6 +984,23 @@ 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" }, + }, + }); + } + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c..1ceb1c00c66 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -131,6 +131,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; }; @@ -141,6 +142,7 @@ function claudeCapabilities(overrides: Partial = {}) { email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiKeySource: undefined, apiProvider: undefined, slashCommands: [], ...overrides, @@ -1820,6 +1822,96 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + 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", apiProvider: "firstParty" }), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((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( + mockSpawnerLayer((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( + mockSpawnerLayer((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( + mockSpawnerLayer((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("includes Claude Opus 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/docs/fork/0015-a-logged-out-claude-install-reads-as-logged-out.md b/docs/fork/0015-a-logged-out-claude-install-reads-as-logged-out.md new file mode 100644 index 00000000000..c78038f2c0f --- /dev/null +++ b/docs/fork/0015-a-logged-out-claude-install-reads-as-logged-out.md @@ -0,0 +1,39 @@ +# 0015: A logged-out Claude install reads as logged out + +- PR: [TrogonStack/t3code#26](https://github.com/TrogonStack/t3code/pull/26) +- Status: active + +## What you can do now + +- Tell at a glance whether a Claude instance can actually run. One whose CLI + holds no credentials reads as not authenticated in Settings and in the chat + banner, with the prompt to sign in that every other signed-out provider + already gets, instead of claiming to be authenticated and failing on the + first message you send it. +- Keep trusting the badge on instances that authenticate in the less common + ways. API-key installs, Bedrock and Vertex backends, and gateway or profile + setups still report as authenticated, none of which carry an account the way + a signed-in first-party install does. + +## Why + +Settings answers one question: is this provider working. Claude answered it by +printing "Authenticated" for any instance whose CLI started at all, which is a +different question from whether that CLI has anything to authenticate with. + +The gap only opens where several Claude accounts run side by side, each bound +to its own credential store, and that is exactly where the answer needs to be +right: instances that had never held a credential showed the same green badge +as the working one, and the only way to find out which was which was to start +a thread and watch it fail. A status that is correct in the ordinary case and +wrong in precisely the case you consulted it for is worse than no status, +because it spends the trust that makes the rest of the page worth reading. + +## Upstream considerations + +Nothing here is fork-specific, so this belongs upstream as an ordinary bug +fix. Submit it, then delete this entry once it merges. It sits in the shared +Claude provider status check, so a sync must not drop it. The rebase burden is +small: the decision is one exported function over the capability probe's own +fields, and the only other change is a field the probe already had from the +SDK and was discarding. diff --git a/docs/fork/README.md b/docs/fork/README.md index ad82f8987e7..760d71ed627 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -45,3 +45,5 @@ Each entry uses these sections: active, [#21](https://github.com/TrogonStack/t3code/pull/21) - **0013** [Keep your place in a long review](./0013-keep-your-place-in-a-review.md) active, [#23](https://github.com/TrogonStack/t3code/pull/23) +- **0015** [A logged-out Claude install reads as logged out](./0015-a-logged-out-claude-install-reads-as-logged-out.md) + active, [#26](https://github.com/TrogonStack/t3code/pull/26)