Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: "none"` means no API key is in use, so it is no evidence either.
*/
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.
const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none";
return hasApiKey || capabilities.email || capabilities.subscriptionType
? "authenticated"
: "unauthenticated";
}

// ── 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
97 changes: 97 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,100 @@ 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",
apiKeySource: "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 +3300,7 @@ it.layer(
email: undefined,
subscriptionType: undefined,
tokenSource: undefined,
apiKeySource: undefined,
apiProvider: undefined,
slashCommands: [],
usage: { rate_limits_available: true, rate_limits: {} },
Expand Down
Loading