Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
initialSnapshot: (settings) =>
makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
makePendingClaudeProvider(settings.provider, processEnv).pipe(
Effect.map(stampIdentity),
Effect.provideService(Path.Path, path),
),
checkProvider,
enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
Expand Down
110 changes: 108 additions & 2 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ModelCapabilities,
type ModelSelection,
type ServerProviderModel,
type ServerProviderReauthentication,
type ServerProviderSlashCommand,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -37,7 +38,7 @@ import {
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { makeClaudeEnvironment, resolveClaudeHomePath } from "../Drivers/ClaudeHome.ts";

const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
optionDescriptors: [],
Expand Down Expand Up @@ -394,6 +395,90 @@ export function resolveClaudeApiModelId(modelSelection: ModelSelection): string
}
}

const CLAUDE_REAUTHENTICATION_ARGS = ["setup-token"] as const;

const FALSY_ENV_FLAG_VALUES = new Set(["", "0", "false", "no", "off", "n"]);

function isTruthyEnvFlag(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
if (normalized === undefined) return false;
return !FALSY_ENV_FLAG_VALUES.has(normalized);
}

/**
* Whether `claude setup-token` (interactive first-party OAuth login) is a valid
* recovery for this instance's credentials.
*
* OAuth login only applies to first-party Anthropic auth. When the instance is
* configured for a non-OAuth backend, `setup-token` cannot fix a credential
* failure and offering it would be misleading, so we skip the re-authenticate
* action entirely. Detected from the resolved instance environment (the same
* env Claude Code itself reads to select a backend):
* - AWS Bedrock (`CLAUDE_CODE_USE_BEDROCK`) or Google Vertex
* (`CLAUDE_CODE_USE_VERTEX`) — credentials come from AWS/GCP, not OAuth.
* - An explicit API key (`ANTHROPIC_API_KEY`) or auth token
* (`ANTHROPIC_AUTH_TOKEN`, e.g. OpenRouter / gateways) — the fix is to
* correct that key/token, not to run an OAuth login.
* The default (none of these set) is first-party OAuth, so the action is shown.
*/
function claudeUsesOAuthLogin(environment: NodeJS.ProcessEnv): boolean {
if (isTruthyEnvFlag(environment.CLAUDE_CODE_USE_BEDROCK)) return false;
if (isTruthyEnvFlag(environment.CLAUDE_CODE_USE_VERTEX)) return false;
if (nonEmptyProbeString(environment.ANTHROPIC_API_KEY ?? "")) return false;
if (nonEmptyProbeString(environment.ANTHROPIC_AUTH_TOKEN ?? "")) return false;
return true;
Comment thread
sideeffffect marked this conversation as resolved.
}

/**
* Build the in-app re-authentication descriptor for a Claude provider
* instance, or `undefined` when OAuth re-authentication does not apply (see
* {@link claudeUsesOAuthLogin} — Bedrock/Vertex/API-key/auth-token instances).
*
* Runs `claude setup-token`, which performs the interactive OAuth login
* (prints a URL, then accepts the pasted authorization code) and stores a
* fresh long-lived token. Surfacing this to the client lets users recover
* from an expired Claude OAuth access token — e.g. a
* `401 OAuth access token has expired` turn failure — from within T3 Code's
* integrated terminal instead of dropping to an external shell.
*
* The configured `binaryPath` is preserved so custom Claude installs
* re-authenticate the same binary they run, and `CLAUDE_CONFIG_DIR` is
* propagated so `setup-token` refreshes the credentials of that exact instance
* rather than the default config dir. The dir is resolved with the same
* precedence {@link makeClaudeEnvironment} uses: a custom `homePath` wins;
* otherwise a `CLAUDE_CONFIG_DIR` supplied via the instance's own environment
* is honored.
*
* `command` is a plain space-join intended for the common case (`claude` on
* PATH, or a path without spaces) and to stay portable across the integrated
* terminal's shell — deliberately NOT shell-quoted, since no single quoting
* scheme is correct for bash/zsh (`~` expansion), `cmd.exe` (literal single
* quotes), and POSIX paths with spaces at once. Callers that need exact argv
* (e.g. spawning directly rather than pasting into a shell) should use
* `executable` + `args`.
*/
export const resolveClaudeReauthentication = Effect.fn("resolveClaudeReauthentication")(function* (
claudeSettings: ClaudeSettings,
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<ServerProviderReauthentication | undefined, never, Path.Path> {
if (!claudeUsesOAuthLogin(environment)) return undefined;
const executable = claudeSettings.binaryPath?.trim() || "claude";
const args = [...CLAUDE_REAUTHENTICATION_ARGS];
const command = [executable, ...args].join(" ");
const configDir =
claudeSettings.homePath.trim().length > 0
? yield* resolveClaudeHomePath(claudeSettings)
: nonEmptyProbeString(environment.CLAUDE_CONFIG_DIR ?? "");
const env = configDir ? { CLAUDE_CONFIG_DIR: configDir } : undefined;
return {
command,
executable,
args,
label: "Re-authenticate Claude",
...(env ? { env } : {}),
} satisfies ServerProviderReauthentication;
});

function toTitleCaseWords(value: string): string {
const parts: Array<string> = [];
for (const part of value.split(/[\s_-]+/g)) {
Expand Down Expand Up @@ -703,6 +788,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
> {
const resolvedEnvironment = environment ?? process.env;
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const reauthentication = yield* resolveClaudeReauthentication(
claudeSettings,
resolvedEnvironment,
);
const allModels = providerModelsFromSettings(
BUILT_IN_MODELS,
claudeSettings.customModels,
Expand Down Expand Up @@ -741,6 +830,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: allModels,
// Offer in-app re-auth only when the binary is actually present — a
// missing CLI can't be re-authenticated, but an installed one that
// failed its health check still might be (e.g. expired credentials).
...(isCommandMissingCause(error) ? {} : { reauthentication }),
probe: {
installed: !isCommandMissingCause(error),
version: null,
Expand All @@ -759,6 +852,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: allModels,
reauthentication,
probe: {
installed: true,
version: null,
Expand All @@ -783,6 +877,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: allModels,
reauthentication,
probe: {
installed: true,
version: parsedVersion,
Expand Down Expand Up @@ -819,6 +914,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
reauthentication,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -840,6 +936,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
reauthentication,
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
sideeffffect marked this conversation as resolved.
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -858,7 +955,8 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

export const makePendingClaudeProvider = (
claudeSettings: ClaudeSettings,
): Effect.Effect<ServerProviderDraft> =>
environment?: NodeJS.ProcessEnv,
): Effect.Effect<ServerProviderDraft, never, Path.Path> =>
Effect.gen(function* () {
const checkedAt = yield* nowIso;
const models = providerModelsFromSettings(
Expand All @@ -883,11 +981,19 @@ export const makePendingClaudeProvider = (
});
}

// Expose re-authentication even on the pre-probe snapshot so a turn that
// fails with an auth error before the first status check completes can
// still offer the in-app recovery action.
const reauthentication = yield* resolveClaudeReauthentication(
claudeSettings,
environment ?? process.env,
);
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: true,
checkedAt,
models,
reauthentication,
probe: {
installed: false,
version: null,
Expand Down
116 changes: 116 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,10 +1489,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities(),
// Explicit clean env so the OAuth-gated re-auth descriptor does not
// depend on ambient ANTHROPIC_API_KEY/backend vars in CI.
{},
);
assert.strictEqual(status.status, "ready");
assert.strictEqual(status.installed, true);
assert.strictEqual(status.auth.status, "authenticated");
assert.deepStrictEqual(status.reauthentication, {
command: "claude setup-token",
executable: "claude",
args: ["setup-token"],
label: "Re-authenticate Claude",
});
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
Expand All @@ -1510,6 +1519,107 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
),
);

it.effect("propagates CLAUDE_CONFIG_DIR re-auth env for a custom Claude home", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus(
{ ...defaultClaudeSettings, homePath: "/tmp/t3-claude-home" },
claudeCapabilities(),
{},
);
assert.strictEqual(status.reauthentication?.command, "claude setup-token");
assert.deepStrictEqual(status.reauthentication?.env, {
CLAUDE_CONFIG_DIR: "/tmp/t3-claude-home",
});
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
if (joined === "auth status")
return {
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
stderr: "",
code: 0,
};
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("honors an instance-env CLAUDE_CONFIG_DIR in the re-auth descriptor", () =>
Effect.gen(function* () {
// No custom homePath, but the instance environment supplies a config
// dir directly — setup-token must target it, not the default dir.
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities(),
{
CLAUDE_CONFIG_DIR: "/tmp/env-claude-cfg",
},
);
assert.deepStrictEqual(status.reauthentication?.env, {
CLAUDE_CONFIG_DIR: "/tmp/env-claude-cfg",
});
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
if (joined === "auth status")
return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 };
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("keeps re-authentication when a backend flag is set to a falsy value", () =>
Effect.gen(function* () {
// `CLAUDE_CODE_USE_BEDROCK=no` means Bedrock is OFF, so the instance
// still uses first-party OAuth and the action must remain available.
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities(),
{
CLAUDE_CODE_USE_BEDROCK: "no",
},
);
assert.strictEqual(status.reauthentication?.command, "claude setup-token");
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
if (joined === "auth status")
return { stdout: '{"loggedIn":true}\n', stderr: "", code: 0 };
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("omits re-authentication for a Bedrock-backed Claude instance", () =>
Effect.gen(function* () {
// Bedrock authenticates via external AWS credentials; `setup-token`
// (OAuth) cannot fix its auth, so no re-authenticate action is offered.
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities({ apiProvider: "bedrock" }),
{ CLAUDE_CODE_USE_BEDROCK: "1" },
);
assert.strictEqual(status.reauthentication, undefined);
}).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 ready and labels Bedrock-backed Claude as authenticated", () =>
Effect.gen(function* () {
// Bedrock authenticates via external AWS credentials, so the SDK init
Expand Down Expand Up @@ -1914,6 +2024,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
status.message,
"Claude Agent CLI (`claude`) is not installed or not on PATH.",
);
// A missing binary cannot be re-authenticated, so no action is offered.
assert.strictEqual(status.reauthentication, undefined);
}).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))),
);

Expand All @@ -1923,11 +2035,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const status = yield* checkClaudeProviderStatus(
defaultClaudeSettings,
claudeCapabilities(),
{},
);
assert.strictEqual(status.status, "error");
assert.strictEqual(status.installed, true);
assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run.");
assert.ok(!(status.message ?? "").includes(secretStderr));
// An installed CLI that failed its health check may still be
// recoverable (e.g. expired credentials), so re-auth is offered.
assert.strictEqual(status.reauthentication?.command, "claude setup-token");
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/providerSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ModelCapabilities,
ServerProvider,
ServerProviderAuth,
ServerProviderReauthentication,
ServerProviderSkill,
ServerProviderSlashCommand,
ServerProviderModel,
Expand Down Expand Up @@ -215,6 +216,7 @@ export function buildServerProvider(input: {
models: ReadonlyArray<ServerProviderModel>;
slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
skills?: ReadonlyArray<ServerProviderSkill>;
reauthentication?: ServerProviderReauthentication | undefined;
probe: ProviderProbeResult;
}): ServerProviderDraft {
const versionAdvisory = input.driver
Expand Down Expand Up @@ -243,6 +245,7 @@ export function buildServerProvider(input: {
models: input.models,
slashCommands: [...(input.slashCommands ?? [])],
skills: [...(input.skills ?? [])],
...(input.reauthentication ? { reauthentication: input.reauthentication } : {}),
...(versionAdvisory ? { versionAdvisory } : {}),
};
}
Expand Down
Loading
Loading