authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-10-09 01:12:46-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 22:12:46-07:00
log563645cf6e104ad930f4c9b209678a77eef07e65
treef421f9ba9074c33d03c666da57a415f8e7dc3408
parent8f760371978f05b49e6f6b6aa4b2c523ab14b4fe
signature Signed by PGP key B5690EEEBB952194

fix(server): a logged-out Claude CLI no longer reports as authenticated (#15459)


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) => {
279279 email: "dev@example.com",
280280 subscriptionType: "pro",
281281 tokenSource: "oauth",
282 apiKeySource: undefined,
282283 apiProvider: undefined,
283284 slashCommands: [
284285 {
apps/server/src/provider/ClaudeProvider.ts+54
......@@ -167,6 +167,38 @@ function apiProviderAuthMetadata(
167167 return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined;
168168}
169169
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 */
184function 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
170202// ── SDK capability probe ────────────────────────────────────────────
171203
172204// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the
......@@ -232,6 +264,8 @@ type ClaudeCapabilitiesProbe = {
232264 readonly email: string | undefined;
233265 readonly subscriptionType: string | undefined;
234266 readonly tokenSource: string | undefined;
267 /** Where the CLI found an API key, when it authenticates with one. */
268 readonly apiKeySource: string | undefined;
235269 /**
236270 * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth
237271 * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`)
......@@ -387,6 +421,7 @@ const probeClaudeCapabilities = (
387421 readonly email?: string;
388422 readonly subscriptionType?: string;
389423 readonly tokenSource?: string;
424 readonly apiKeySource?: string;
390425 readonly apiProvider?: string;
391426 }
392427 | undefined;
......@@ -394,6 +429,7 @@ const probeClaudeCapabilities = (
394429 email: account?.email,
395430 subscriptionType: account?.subscriptionType,
396431 tokenSource: account?.tokenSource,
432 apiKeySource: account?.apiKeySource,
397433 apiProvider: account?.apiProvider,
398434 slashCommands: parseClaudeInitializationCommands(init.commands),
399435 ...(usage ? { usage } : {}),
......@@ -590,6 +626,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
590626 });
591627 }
592628
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
593647 const authMetadata =
594648 claudeAuthMetadata({
595649 subscriptionType: capabilities.subscriptionType,
apps/server/src/provider/ProviderRegistry.test.ts+97
......@@ -164,6 +164,7 @@ type TestClaudeCapabilities = {
164164 readonly email: string | undefined;
165165 readonly subscriptionType: string | undefined;
166166 readonly tokenSource: string | undefined;
167 readonly apiKeySource: string | undefined;
167168 readonly apiProvider: string | undefined;
168169 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
169170};
......@@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
174175 email: undefined,
175176 subscriptionType: undefined,
176177 tokenSource: undefined,
178 apiKeySource: undefined,
177179 apiProvider: undefined,
178180 slashCommands: [],
179181 ...overrides,
......@@ -3167,6 +3169,100 @@ it.layer(
31673169 ),
31683170 );
31693171
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
31703266 it.effect("returns a display label for claude subscription types", () =>
31713267 Effect.gen(function* () {
31723268 const status = yield* checkClaudeProviderStatus(
......@@ -3204,6 +3300,7 @@ it.layer(
32043300 email: undefined,
32053301 subscriptionType: undefined,
32063302 tokenSource: undefined,
3303 apiKeySource: undefined,
32073304 apiProvider: undefined,
32083305 slashCommands: [],
32093306 usage: { rate_limits_available: true, rate_limits: {} },