| author | |
| committer | |
| log | 563645cf6e104ad930f4c9b209678a77eef07e65 |
| tree | f421f9ba9074c33d03c666da57a415f8e7dc3408 |
| parent | 8f760371978f05b49e6f6b6aa4b2c523ab14b4fe |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 152 insertions(+), 0 deletions(-)
apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts+1| ... | ... | @@ -279,6 +279,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { |
| 279 | 279 | email: "dev@example.com", |
| 280 | 280 | subscriptionType: "pro", |
| 281 | 281 | tokenSource: "oauth", |
| 282 | apiKeySource: undefined, | |
| 282 | 283 | apiProvider: undefined, |
| 283 | 284 | slashCommands: [ |
| 284 | 285 | { |
apps/server/src/provider/ClaudeProvider.ts+54| ... | ... | @@ -167,6 +167,38 @@ function apiProviderAuthMetadata( |
| 167 | 167 | return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; |
| 168 | 168 | } |
| 169 | 169 | |
| 170 | /** | |
| 171 | * Whether the SDK's account payload evidences a credential the CLI can use. | |
| 172 | * | |
| 173 | * The capability probe resolves for a logged-out CLI, so a completed probe only | |
| 174 | * proves Claude Code started. `tokenSource: "none"` is the CLI reporting it | |
| 175 | * found no token at all, and is the one shape that disproves authentication. | |
| 176 | * Everything else either names a credential or, on a third-party backend, omits | |
| 177 | * these fields by design because auth lives with AWS or gcloud instead. | |
| 178 | * | |
| 179 | * Silence is deliberately not disproof. Profile-authenticated installs report no | |
| 180 | * token source, and a CLI too old to send an account payload reports nothing at | |
| 181 | * all; treating either as logged out would sign working setups out of Settings. | |
| 182 | * `apiKeySource: "none"` means no API key is in use, so it is no evidence either. | |
| 183 | */ | |
| 184 | function claudeAuthStatus( | |
| 185 | capabilities: Pick< | |
| 186 | ClaudeCapabilitiesProbe, | |
| 187 | "email" | "subscriptionType" | "tokenSource" | "apiKeySource" | "apiProvider" | |
| 188 | >, | |
| 189 | ): "authenticated" | "unauthenticated" { | |
| 190 | if (capabilities.apiProvider !== undefined && capabilities.apiProvider !== "firstParty") { | |
| 191 | return "authenticated"; | |
| 192 | } | |
| 193 | if (capabilities.tokenSource !== "none") return "authenticated"; | |
| 194 | // An `ANTHROPIC_API_KEY` install reports no token source but is authenticated | |
| 195 | // all the same, so the key and account fields still get a say. | |
| 196 | const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; | |
| 197 | return hasApiKey || capabilities.email || capabilities.subscriptionType | |
| 198 | ? "authenticated" | |
| 199 | : "unauthenticated"; | |
| 200 | } | |
| 201 | ||
| 170 | 202 | // ── SDK capability probe ──────────────────────────────────────────── |
| 171 | 203 | |
| 172 | 204 | // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the |
| ... | ... | @@ -232,6 +264,8 @@ type ClaudeCapabilitiesProbe = { |
| 232 | 264 | readonly email: string | undefined; |
| 233 | 265 | readonly subscriptionType: string | undefined; |
| 234 | 266 | readonly tokenSource: string | undefined; |
| 267 | /** Where the CLI found an API key, when it authenticates with one. */ | |
| 268 | readonly apiKeySource: string | undefined; | |
| 235 | 269 | /** |
| 236 | 270 | * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth |
| 237 | 271 | * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) |
| ... | ... | @@ -387,6 +421,7 @@ const probeClaudeCapabilities = ( |
| 387 | 421 | readonly email?: string; |
| 388 | 422 | readonly subscriptionType?: string; |
| 389 | 423 | readonly tokenSource?: string; |
| 424 | readonly apiKeySource?: string; | |
| 390 | 425 | readonly apiProvider?: string; |
| 391 | 426 | } |
| 392 | 427 | | undefined; |
| ... | ... | @@ -394,6 +429,7 @@ const probeClaudeCapabilities = ( |
| 394 | 429 | email: account?.email, |
| 395 | 430 | subscriptionType: account?.subscriptionType, |
| 396 | 431 | tokenSource: account?.tokenSource, |
| 432 | apiKeySource: account?.apiKeySource, | |
| 397 | 433 | apiProvider: account?.apiProvider, |
| 398 | 434 | slashCommands: parseClaudeInitializationCommands(init.commands), |
| 399 | 435 | ...(usage ? { usage } : {}), |
| ... | ... | @@ -590,6 +626,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 590 | 626 | }); |
| 591 | 627 | } |
| 592 | 628 | |
| 629 | if (claudeAuthStatus(capabilities) === "unauthenticated") { | |
| 630 | return buildServerProvider({ | |
| 631 | presentation: CLAUDE_PRESENTATION, | |
| 632 | enabled: claudeSettings.enabled, | |
| 633 | checkedAt, | |
| 634 | models, | |
| 635 | slashCommands: dedupedSlashCommands, | |
| 636 | skills, | |
| 637 | probe: { | |
| 638 | installed: true, | |
| 639 | version: parsedVersion, | |
| 640 | status: "error", | |
| 641 | auth: { status: "unauthenticated" }, | |
| 642 | message: "Claude Code is not authenticated. Run `claude auth login` and try again.", | |
| 643 | }, | |
| 644 | }); | |
| 645 | } | |
| 646 | ||
| 593 | 647 | const authMetadata = |
| 594 | 648 | claudeAuthMetadata({ |
| 595 | 649 | subscriptionType: capabilities.subscriptionType, |
apps/server/src/provider/ProviderRegistry.test.ts+97| ... | ... | @@ -164,6 +164,7 @@ type TestClaudeCapabilities = { |
| 164 | 164 | readonly email: string | undefined; |
| 165 | 165 | readonly subscriptionType: string | undefined; |
| 166 | 166 | readonly tokenSource: string | undefined; |
| 167 | readonly apiKeySource: string | undefined; | |
| 167 | 168 | readonly apiProvider: string | undefined; |
| 168 | 169 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; |
| 169 | 170 | }; |
| ... | ... | @@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) { |
| 174 | 175 | email: undefined, |
| 175 | 176 | subscriptionType: undefined, |
| 176 | 177 | tokenSource: undefined, |
| 178 | apiKeySource: undefined, | |
| 177 | 179 | apiProvider: undefined, |
| 178 | 180 | slashCommands: [], |
| 179 | 181 | ...overrides, |
| ... | ... | @@ -3167,6 +3169,100 @@ it.layer( |
| 3167 | 3169 | ), |
| 3168 | 3170 | ); |
| 3169 | 3171 | |
| 3172 | it.effect("reports a logged-out CLI as unauthenticated", () => | |
| 3173 | Effect.gen(function* () { | |
| 3174 | // The capability probe resolves for a logged-out CLI, so `tokenSource: | |
| 3175 | // "none"` is the only thing separating it from an authenticated one. | |
| 3176 | const status = yield* checkClaudeProviderStatus( | |
| 3177 | defaultClaudeSettings, | |
| 3178 | claudeCapabilities({ | |
| 3179 | tokenSource: "none", | |
| 3180 | apiKeySource: "none", | |
| 3181 | apiProvider: "firstParty", | |
| 3182 | }), | |
| 3183 | ); | |
| 3184 | assert.strictEqual(status.status, "error"); | |
| 3185 | assert.strictEqual(status.auth.status, "unauthenticated"); | |
| 3186 | }).pipe( | |
| 3187 | Effect.provide( | |
| 3188 | layerMockSpawner((args) => { | |
| 3189 | const joined = args.join(" "); | |
| 3190 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 3191 | throw new Error(`Unexpected args: ${joined}`); | |
| 3192 | }), | |
| 3193 | ), | |
| 3194 | ), | |
| 3195 | ); | |
| 3196 | ||
| 3197 | it.effect("keeps an API key install authenticated when it reports no token source", () => | |
| 3198 | Effect.gen(function* () { | |
| 3199 | // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that | |
| 3200 | // field alone would log the install out. | |
| 3201 | const status = yield* checkClaudeProviderStatus( | |
| 3202 | defaultClaudeSettings, | |
| 3203 | claudeCapabilities({ | |
| 3204 | tokenSource: "none", | |
| 3205 | apiKeySource: "ANTHROPIC_API_KEY", | |
| 3206 | apiProvider: "firstParty", | |
| 3207 | }), | |
| 3208 | ); | |
| 3209 | assert.strictEqual(status.status, "ready"); | |
| 3210 | assert.strictEqual(status.auth.status, "authenticated"); | |
| 3211 | }).pipe( | |
| 3212 | Effect.provide( | |
| 3213 | layerMockSpawner((args) => { | |
| 3214 | const joined = args.join(" "); | |
| 3215 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 3216 | throw new Error(`Unexpected args: ${joined}`); | |
| 3217 | }), | |
| 3218 | ), | |
| 3219 | ), | |
| 3220 | ); | |
| 3221 | ||
| 3222 | it.effect("keeps a third-party backend authenticated without any token source", () => | |
| 3223 | Effect.gen(function* () { | |
| 3224 | // Bedrock and Vertex authenticate outside the CLI, so the account | |
| 3225 | // payload is empty by design rather than because nobody logged in. | |
| 3226 | const status = yield* checkClaudeProviderStatus( | |
| 3227 | defaultClaudeSettings, | |
| 3228 | claudeCapabilities({ tokenSource: "none", apiProvider: "bedrock" }), | |
| 3229 | ); | |
| 3230 | assert.strictEqual(status.status, "ready"); | |
| 3231 | assert.strictEqual(status.auth.status, "authenticated"); | |
| 3232 | }).pipe( | |
| 3233 | Effect.provide( | |
| 3234 | layerMockSpawner((args) => { | |
| 3235 | const joined = args.join(" "); | |
| 3236 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 3237 | throw new Error(`Unexpected args: ${joined}`); | |
| 3238 | }), | |
| 3239 | ), | |
| 3240 | ), | |
| 3241 | ); | |
| 3242 | ||
| 3243 | it.effect("keeps a CLI that says nothing about its account authenticated", () => | |
| 3244 | Effect.gen(function* () { | |
| 3245 | // Profile-authenticated installs report no token source at all, and a | |
| 3246 | // CLI too old to send an account payload reports nothing whatsoever. | |
| 3247 | // Only `tokenSource: "none"` disproves authentication; saying nothing | |
| 3248 | // is not the same as saying no. | |
| 3249 | const status = yield* checkClaudeProviderStatus( | |
| 3250 | defaultClaudeSettings, | |
| 3251 | claudeCapabilities(), | |
| 3252 | ); | |
| 3253 | assert.strictEqual(status.status, "ready"); | |
| 3254 | assert.strictEqual(status.auth.status, "authenticated"); | |
| 3255 | }).pipe( | |
| 3256 | Effect.provide( | |
| 3257 | layerMockSpawner((args) => { | |
| 3258 | const joined = args.join(" "); | |
| 3259 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | |
| 3260 | throw new Error(`Unexpected args: ${joined}`); | |
| 3261 | }), | |
| 3262 | ), | |
| 3263 | ), | |
| 3264 | ); | |
| 3265 | ||
| 3170 | 3266 | it.effect("returns a display label for claude subscription types", () => |
| 3171 | 3267 | Effect.gen(function* () { |
| 3172 | 3268 | const status = yield* checkClaudeProviderStatus( |
| ... | ... | @@ -3204,6 +3300,7 @@ it.layer( |
| 3204 | 3300 | email: undefined, |
| 3205 | 3301 | subscriptionType: undefined, |
| 3206 | 3302 | tokenSource: undefined, |
| 3303 | apiKeySource: undefined, | |
| 3207 | 3304 | apiProvider: undefined, |
| 3208 | 3305 | slashCommands: [], |
| 3209 | 3306 | usage: { rate_limits_available: true, rate_limits: {} }, |