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
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
email: "[email protected]",
subscriptionType: "pro",
tokenSource: "oauth",
apiKeySource: undefined,
apiProvider: undefined,
slashCommands: [
{
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,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.
*/
export 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
yordis marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the
Expand Down Expand Up @@ -635,6 +667,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 @@ -762,13 +796,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),
} satisfies ClaudeCapabilitiesProbe;
Expand Down Expand Up @@ -948,6 +984,23 @@ 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" },
},
});
}

const authMetadata =
claudeAuthMetadata({
subscriptionType: capabilities.subscriptionType,
Expand Down
92 changes: 92 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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 @@ -141,6 +142,7 @@ function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
email: undefined,
subscriptionType: undefined,
tokenSource: undefined,
apiKeySource: undefined,
apiProvider: undefined,
slashCommands: [],
...overrides,
Expand Down Expand Up @@ -1820,6 +1822,96 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
),
);

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(
mockSpawnerLayer((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(
mockSpawnerLayer((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(
mockSpawnerLayer((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(
mockSpawnerLayer((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("includes Claude Opus 5 on supported Claude Code versions", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
Expand Down
39 changes: 39 additions & 0 deletions docs/fork/0015-a-logged-out-claude-install-reads-as-logged-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 0015: A logged-out Claude install reads as logged out

- PR: [TrogonStack/t3code#26](https://github.com/TrogonStack/t3code/pull/26)
- Status: active

## What you can do now

- Tell at a glance whether a Claude instance can actually run. One whose CLI
holds no credentials reads as not authenticated in Settings and in the chat
banner, with the prompt to sign in that every other signed-out provider
already gets, instead of claiming to be authenticated and failing on the
first message you send it.
- Keep trusting the badge on instances that authenticate in the less common
ways. API-key installs, Bedrock and Vertex backends, and gateway or profile
setups still report as authenticated, none of which carry an account the way
a signed-in first-party install does.

## Why

Settings answers one question: is this provider working. Claude answered it by
printing "Authenticated" for any instance whose CLI started at all, which is a
different question from whether that CLI has anything to authenticate with.

The gap only opens where several Claude accounts run side by side, each bound
to its own credential store, and that is exactly where the answer needs to be
right: instances that had never held a credential showed the same green badge
as the working one, and the only way to find out which was which was to start
a thread and watch it fail. A status that is correct in the ordinary case and
wrong in precisely the case you consulted it for is worse than no status,
because it spends the trust that makes the rest of the page worth reading.

## Upstream considerations

Nothing here is fork-specific, so this belongs upstream as an ordinary bug
fix. Submit it, then delete this entry once it merges. It sits in the shared
Claude provider status check, so a sync must not drop it. The rebase burden is
small: the decision is one exported function over the capability probe's own
fields, and the only other change is a field the probe already had from the
SDK and was discarding.
2 changes: 2 additions & 0 deletions docs/fork/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ Each entry uses these sections:
active, [#21](https://github.com/TrogonStack/t3code/pull/21)
- **0013** [Keep your place in a long review](./0013-keep-your-place-in-a-review.md)
active, [#23](https://github.com/TrogonStack/t3code/pull/23)
- **0015** [A logged-out Claude install reads as logged out](./0015-a-logged-out-claude-install-reads-as-logged-out.md)
active, [#26](https://github.com/TrogonStack/t3code/pull/26)
Loading