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
52 changes: 50 additions & 2 deletions 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 @@ -228,6 +228,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 @@ -377,13 +382,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 @@ -537,7 +544,18 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);

if (!capabilities) {
// Account metadata is how the CLI tells us who it is signed in as. An init
// without any of it (older CLIs, or an SDK/CLI mismatch) cannot be trusted
// as authenticated, so it lands on the same unverified status as a failed
// probe.
const hasAccountMetadata =
capabilities !== undefined &&
(capabilities.email !== undefined ||
capabilities.tokenSource !== undefined ||
capabilities.apiKeySource !== undefined ||
capabilities.subscriptionType !== undefined ||
capabilities.apiProvider !== undefined);
if (!capabilities || !hasAccountMetadata) {
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: claudeSettings.enabled,
Expand All @@ -555,6 +573,36 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
});
}

// A logged-out first-party CLI still initializes, reporting tokenSource
// "none" and no apiKeySource. API-key setups share the tokenSource but the
// CLI names their key source, so they stay authenticated.
if (
capabilities.apiProvider === "firstParty" &&
capabilities.tokenSource === "none" &&
!capabilities.apiKeySource
Comment thread
none23 marked this conversation as 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.
configDir: claudeEnvironment.CLAUDE_CONFIG_DIR,
cwd: path.resolve(cwd ?? "."),
}),
},
});
}

const authMetadata =
claudeAuthMetadata({
subscriptionType: capabilities.subscriptionType,
Expand Down
69 changes: 67 additions & 2 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 @@ -2649,7 +2651,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities(),
claudeCapabilities({ tokenSource: "oauth" }),
);
assert.strictEqual(status.status, "ready");
assert.strictEqual(status.installed, true);
Expand Down Expand Up @@ -2766,6 +2768,69 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
),
);

it.effect("reports unknown auth when the initialization result has no account metadata", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities(),
);
assert.strictEqual(status.status, "warning");
assert.strictEqual(status.auth.status, "unknown");
}).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("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}`);
}),
),
),
);

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 Expand Up @@ -2812,7 +2877,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
...defaultClaudeSettings,
homePath: claudeConfigDir,
},
claudeCapabilities(),
claudeCapabilities({ tokenSource: "oauth" }),
);
assert.strictEqual(status.status, "ready");
// The home is resolved through the host Path before it reaches the env.
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