Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` is only ever set when a key was actually found, so it has no
* "no key" sentinel to confuse with one.
*/
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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// ── SDK capability probe ────────────────────────────────────────────

// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the
Expand Down Expand Up @@ -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"`)
Expand Down Expand Up @@ -387,13 +421,15 @@ const probeClaudeCapabilities = (
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiKeySource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiKeySource: account?.apiKeySource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
...(usage ? { usage } : {}),
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProviderSlashCommand>;
};
Expand All @@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
email: undefined,
subscriptionType: undefined,
tokenSource: undefined,
apiKeySource: undefined,
apiProvider: undefined,
slashCommands: [],
...overrides,
Expand Down Expand Up @@ -3167,6 +3169,96 @@ 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", 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(
Expand Down Expand Up @@ -3204,6 +3296,7 @@ it.layer(
email: undefined,
subscriptionType: undefined,
tokenSource: undefined,
apiKeySource: undefined,
apiProvider: undefined,
slashCommands: [],
usage: { rate_limits_available: true, rate_limits: {} },
Expand Down
Loading