| 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,6 +279,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { |
| 279 | email: "dev@example.com", | 279 | email: "dev@example.com", |
| 280 | subscriptionType: "pro", | 280 | subscriptionType: "pro", |
| 281 | tokenSource: "oauth", | 281 | tokenSource: "oauth", |
| 282 | apiKeySource: undefined, | ||
| 282 | apiProvider: undefined, | 283 | apiProvider: undefined, |
| 283 | slashCommands: [ | 284 | slashCommands: [ |
| 284 | { | 285 | { |
apps/server/src/provider/ClaudeProvider.ts+54| ... | @@ -167,6 +167,38 @@ function apiProviderAuthMetadata( | ... | @@ -167,6 +167,38 @@ function apiProviderAuthMetadata( |
| 167 | return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; | 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 | // ── SDK capability probe ──────────────────────────────────────────── | 202 | // ── SDK capability probe ──────────────────────────────────────────── |
| 171 | 203 | ||
| 172 | // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the | 204 | // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the |
| ... | @@ -232,6 +264,8 @@ type ClaudeCapabilitiesProbe = { | ... | @@ -232,6 +264,8 @@ type ClaudeCapabilitiesProbe = { |
| 232 | readonly email: string | undefined; | 264 | readonly email: string | undefined; |
| 233 | readonly subscriptionType: string | undefined; | 265 | readonly subscriptionType: string | undefined; |
| 234 | readonly tokenSource: string | undefined; | 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 | * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth | 270 | * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth |
| 237 | * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) | 271 | * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) |
| ... | @@ -387,6 +421,7 @@ const probeClaudeCapabilities = ( | ... | @@ -387,6 +421,7 @@ const probeClaudeCapabilities = ( |
| 387 | readonly email?: string; | 421 | readonly email?: string; |
| 388 | readonly subscriptionType?: string; | 422 | readonly subscriptionType?: string; |
| 389 | readonly tokenSource?: string; | 423 | readonly tokenSource?: string; |
| 424 | readonly apiKeySource?: string; | ||
| 390 | readonly apiProvider?: string; | 425 | readonly apiProvider?: string; |
| 391 | } | 426 | } |
| 392 | | undefined; | 427 | | undefined; |
| ... | @@ -394,6 +429,7 @@ const probeClaudeCapabilities = ( | ... | @@ -394,6 +429,7 @@ const probeClaudeCapabilities = ( |
| 394 | email: account?.email, | 429 | email: account?.email, |
| 395 | subscriptionType: account?.subscriptionType, | 430 | subscriptionType: account?.subscriptionType, |
| 396 | tokenSource: account?.tokenSource, | 431 | tokenSource: account?.tokenSource, |
| 432 | apiKeySource: account?.apiKeySource, | ||
| 397 | apiProvider: account?.apiProvider, | 433 | apiProvider: account?.apiProvider, |
| 398 | slashCommands: parseClaudeInitializationCommands(init.commands), | 434 | slashCommands: parseClaudeInitializationCommands(init.commands), |
| 399 | ...(usage ? { usage } : {}), | 435 | ...(usage ? { usage } : {}), |
| ... | @@ -590,6 +626,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -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 | const authMetadata = | 647 | const authMetadata = |
| 594 | claudeAuthMetadata({ | 648 | claudeAuthMetadata({ |
| 595 | subscriptionType: capabilities.subscriptionType, | 649 | subscriptionType: capabilities.subscriptionType, |
apps/server/src/provider/ProviderRegistry.test.ts+97| ... | @@ -164,6 +164,7 @@ type TestClaudeCapabilities = { | ... | @@ -164,6 +164,7 @@ type TestClaudeCapabilities = { |
| 164 | readonly email: string | undefined; | 164 | readonly email: string | undefined; |
| 165 | readonly subscriptionType: string | undefined; | 165 | readonly subscriptionType: string | undefined; |
| 166 | readonly tokenSource: string | undefined; | 166 | readonly tokenSource: string | undefined; |
| 167 | readonly apiKeySource: string | undefined; | ||
| 167 | readonly apiProvider: string | undefined; | 168 | readonly apiProvider: string | undefined; |
| 168 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | 169 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; |
| 169 | }; | 170 | }; |
| ... | @@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) { | ... | @@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) { |
| 174 | email: undefined, | 175 | email: undefined, |
| 175 | subscriptionType: undefined, | 176 | subscriptionType: undefined, |
| 176 | tokenSource: undefined, | 177 | tokenSource: undefined, |
| 178 | apiKeySource: undefined, | ||
| 177 | apiProvider: undefined, | 179 | apiProvider: undefined, |
| 178 | slashCommands: [], | 180 | slashCommands: [], |
| 179 | ...overrides, | 181 | ...overrides, |
| ... | @@ -3167,6 +3169,100 @@ it.layer( | ... | @@ -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 | it.effect("returns a display label for claude subscription types", () => | 3266 | it.effect("returns a display label for claude subscription types", () => |
| 3171 | Effect.gen(function* () { | 3267 | Effect.gen(function* () { |
| 3172 | const status = yield* checkClaudeProviderStatus( | 3268 | const status = yield* checkClaudeProviderStatus( |
| ... | @@ -3204,6 +3300,7 @@ it.layer( | ... | @@ -3204,6 +3300,7 @@ it.layer( |
| 3204 | email: undefined, | 3300 | email: undefined, |
| 3205 | subscriptionType: undefined, | 3301 | subscriptionType: undefined, |
| 3206 | tokenSource: undefined, | 3302 | tokenSource: undefined, |
| 3303 | apiKeySource: undefined, | ||
| 3207 | apiProvider: undefined, | 3304 | apiProvider: undefined, |
| 3208 | slashCommands: [], | 3305 | slashCommands: [], |
| 3209 | usage: { rate_limits_available: true, rate_limits: {} }, | 3306 | usage: { rate_limits_available: true, rate_limits: {} }, |