Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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
41 changes: 40 additions & 1 deletion apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
import {
Expand Down Expand Up @@ -227,6 +227,11 @@ type ClaudeCapabilitiesProbe = {
readonly email: string | undefined;
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
/**
* Where an API key came from (e.g. "ANTHROPIC_API_KEY"), reported by the
* CLI alongside `tokenSource: "none"` for key-based first-party auth.
*/
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 @@ -376,13 +381,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 @@ -554,6 +561,38 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
});
}

// A logged-out first-party CLI still initializes and reports tokenSource
// "none" with no apiKeySource. Only that explicit combination counts as
// logged out: API-key setups report tokenSource "none" too but carry an
// apiKeySource, and external-auth backends (Bedrock, Vertex, gateways) and
// older CLIs that omit tokenSource keep their current status.
if (
capabilities.apiProvider === "firstParty" &&
capabilities.tokenSource === "none" &&
!capabilities.apiKeySource
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
none23 marked this conversation as resolved.
Resolved
) {
const path = yield* Path.Path;
const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment);
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: claudeSettings.enabled,
checkedAt,
models,
slashCommands: dedupedSlashCommands,
skills,
probe: {
installed: true,
version: parsedVersion,
status: "error",
auth: { status: "unauthenticated" },
message: claudeSignedOutMessage({
Comment thread
none23 marked this conversation as resolved.
Resolved
configDir: claudeEnvironment.CLAUDE_CONFIG_DIR,
cwd: path.resolve(cwd ?? "."),
}),
},
});
}

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

it.effect("reports unauthenticated when the first-party CLI is logged out", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities({ tokenSource: "none", apiProvider: "firstParty" }),
);
assert.strictEqual(status.status, "error");
assert.strictEqual(status.installed, true);
assert.strictEqual(status.auth.status, "unauthenticated");
assert.match(status.message ?? "", /claude auth login/);
}).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}`);
}),
),
),
);

// An API-key setup also reports tokenSource "none"; only the combination
// with a missing apiKeySource means logged out.
it.effect("stays authenticated when an API key backs a tokenSource of none", () =>
Effect.gen(function* () {
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("returns claude auth email from initialization result", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
Expand Down
7 changes: 7 additions & 0 deletions docs/user/providers-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ Claude Code's verbose mode can stay enabled when you use Claude for text generat
thread titles, branch names, commit messages, and pull request descriptions. On a remote connection,
T3 Code uses the Claude configuration on the connected server.

## Signed out

If **Settings > Providers** shows a Claude instance as not authenticated, its
config directory has no login. Run `claude auth login` on the environment's
machine with the same `CLAUDE_CONFIG_DIR` the instance uses, then start a new
thread. Existing threads keep their signed-out Claude process until they end.

## Compact long conversations

Set **Auto-compact after** in the Claude provider settings to an integer between
Expand Down
Loading