diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949c..2af17e1e257 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -178,7 +178,10 @@ export const ClaudeDriver: ProviderDriver = { 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, { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9df672f54b0..18d2846d9d6 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type ModelSelection, type ServerProviderModel, + type ServerProviderReauthentication, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -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: [], @@ -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; +} + +/** + * 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 { + 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 = []; for (const part of value.split(/[\s_-]+/g)) { @@ -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, @@ -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, @@ -759,6 +852,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + reauthentication, probe: { installed: true, version: null, @@ -783,6 +877,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -819,6 +914,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -840,6 +936,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -858,7 +955,8 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, -): Effect.Effect => + environment?: NodeJS.ProcessEnv, +): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( @@ -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, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 159d853121c..58311379c5f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -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) => { @@ -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 @@ -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"))), ); @@ -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) => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d..8d054d62bc6 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -3,6 +3,7 @@ import type { ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderReauthentication, ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, @@ -215,6 +216,7 @@ export function buildServerProvider(input: { models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; + reauthentication?: ServerProviderReauthentication | undefined; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -243,6 +245,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.reauthentication ? { reauthentication: input.reauthentication } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c578e69c450..e5b901bbe94 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -18,6 +18,7 @@ import { deriveComposerSendState, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isProviderAuthError, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveSendEnvMode, @@ -511,3 +512,40 @@ describe("hasServerAcknowledgedLocalDispatch", () => { expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); }); + +describe("isProviderAuthError", () => { + it("matches the Claude auth failures that should offer in-app re-authentication", () => { + // The exact messages Claude Code surfaces mid-turn when its credential is + // expired or rejected — both must trigger the Re-authenticate affordance. + expect( + isProviderAuthError( + "LLM error: Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue.", + ), + ).toBe(true); + expect( + isProviderAuthError( + "Failed to authenticate. API Error: 401 Invalid authentication credentials", + ), + ).toBe(true); + expect( + isProviderAuthError( + "LLM error: Failed to authenticate. API Error: 401 Invalid authentication credentials", + ), + ).toBe(true); + }); + + it("does not flag generic, non-credential failures", () => { + // Guards against the over-broad heuristic a prior review flagged: bare + // status codes / provider mentions must NOT surface the action. + expect(isProviderAuthError(null)).toBe(false); + expect(isProviderAuthError(undefined)).toBe(false); + expect(isProviderAuthError("")).toBe(false); + expect(isProviderAuthError("Request failed with status 401")).toBe(false); + expect(isProviderAuthError("Tool execution failed: exit code 1")).toBe(false); + expect(isProviderAuthError("Configured OAuth provider settings")).toBe(false); + expect(isProviderAuthError("Set your api key in settings")).toBe(false); + // API-key auth problems are not fixed by the OAuth `setup-token` flow, so + // they must not trigger the re-authenticate action. + expect(isProviderAuthError("API Error: 401 Invalid API key")).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 6b74eae9ff4..23859043f1a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -27,6 +27,32 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +/** + * Heuristic for whether a thread/turn error stems from an expired, missing, or + * rejected provider credential — the signal that makes an in-app + * "Re-authenticate" action worth offering automatically. + * + * This must match the auth failures Claude Code surfaces mid-turn, e.g.: + * - "Failed to authenticate. API Error: 401 OAuth access token has expired. + * Re-authenticate to continue." + * - "Failed to authenticate. API Error: 401 Invalid authentication + * credentials" + * + * It stays deliberately narrow: it keys off OAuth-credential-specific phrasing + * ("failed to authenticate", "invalid authentication", expired/invalid token + * or credentials, re-authenticate, please sign in) rather than bare tokens + * like `401`/`403`/`oauth`, which also appear in generic tool or HTTP failures. + * It intentionally does NOT match "api key" phrasing: an invalid Anthropic API + * key is not fixed by the OAuth `claude setup-token` flow this action runs. + * Covered by regression tests in ChatView.logic.test.ts. + */ +export function isProviderAuthError(message: string | null | undefined): boolean { + if (!message) return false; + return /(?:re-?authenticate|\breauth\b|failed to authenticate|not (?:logged in|authenticated)|unauthenticated|authentication (?:failed|error|required)|invalid authentication|access token (?:has )?expired|expired[\w ]*token|invalid[\w ]*(?:credential|access token|token)|please (?:log ?in|sign ?in)|(?:log|sign) ?in again)/i.test( + message, + ); +} + export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f3145df4500..5dd6b6985d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10,6 +10,7 @@ import { type ProviderApprovalDecision, ProviderInstanceId, type ServerProvider, + type ServerProviderReauthentication, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -233,6 +234,7 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, getStartedThreadModelChangeBlockReason, + isProviderAuthError, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, @@ -266,6 +268,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; + const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -2290,6 +2293,21 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + // The thread error banner's re-authenticate action must target the provider + // instance that actually ran the failing turn (the thread session's + // provider), not whatever the composer currently has selected — otherwise + // switching the picker after a failure could re-authenticate the wrong + // Claude instance. Falls back to the active provider before a session exists. + const sessionProviderInstanceId = activeThread?.session?.providerInstanceId ?? null; + const threadErrorProviderStatus = useMemo(() => { + if (sessionProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === sessionProviderInstanceId) ?? + activeProviderStatus + ); + } + return activeProviderStatus; + }, [sessionProviderInstanceId, providerStatuses, activeProviderStatus]); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -2703,6 +2721,35 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const reauthenticateProvider = useCallback( + (reauthentication: ServerProviderReauthentication) => { + // Reuse the project-script launcher so re-authentication runs through + // the same proven "open/reuse a terminal, focus it, write the command" + // path. A fresh terminal keeps the interactive OAuth prompt (URL + + // pasted code) from colliding with an in-flight shell, and + // `rememberAsLastInvoked: false` keeps this synthetic command out of the + // per-project "last run script" state. + void runProjectScript( + { + id: "__t3-code-reauthenticate__", + name: reauthentication.label ?? "Re-authenticate", + command: reauthentication.command, + icon: "configure", + runOnWorktreeCreate: false, + }, + { + preferNewTerminal: true, + rememberAsLastInvoked: false, + // Carry the provider's isolation env (e.g. CLAUDE_CONFIG_DIR for a + // custom Claude home) so the login refreshes the correct instance's + // credentials rather than the default config dir. + ...(reauthentication.env ? { env: { ...reauthentication.env } } : {}), + }, + ); + }, + [runProjectScript], + ); + const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -5238,11 +5285,29 @@ function ChatViewContent(props: ChatViewProps) { {/* Error banner */} - - setThreadError(activeThread.id, null)} + + {(() => { + const reauth = threadErrorProviderStatus?.reauthentication; + if (!reauth || !isProviderAuthError(threadError)) { + return ( + setThreadError(activeThread.id, null)} + /> + ); + } + return ( + setThreadError(activeThread.id, null)} + onReauthenticate={() => reauthenticateProvider(reauth)} + {...(reauth.label ? { reauthenticateLabel: reauth.label } : {})} + /> + ); + })()} {/* Main content area with optional plan sidebar */}
{/* Chat column */} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index c725b3e275a..c8f9c13df99 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,14 +1,23 @@ -import { type ServerProvider } from "@t3tools/contracts"; +import { type ServerProvider, type ServerProviderReauthentication } from "@t3tools/contracts"; import { memo } from "react"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, KeyRoundIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { formatProviderDriverKindLabel } from "../../providerModels"; +import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export const ProviderStatusBanner = memo(function ProviderStatusBanner({ status, + onReauthenticate, }: { status: ServerProvider | null; + /** + * Invoked when the user clicks the in-app "Re-authenticate" action. Only + * offered when the provider is unauthenticated and advertised a + * `reauthentication` descriptor. Runs the login command inside the thread's + * integrated terminal. + */ + onReauthenticate?: (reauthentication: ServerProviderReauthentication) => void; }) { if (!status || status.status === "ready" || status.status === "disabled") { return null; @@ -16,11 +25,16 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; + const reauthentication = status.reauthentication ?? null; + const canReauthenticate = + isUnauthenticated && Boolean(reauthentication) && Boolean(onReauthenticate); const title = isUnauthenticated ? `${providerName} is unauthenticated` : `${providerName} provider status`; const message = isUnauthenticated - ? "Sign in via the CLI to authenticate again." + ? canReauthenticate + ? "Re-authenticate to keep using this provider." + : "Sign in via the CLI to authenticate again." : (status.message ?? (status.status === "error" ? `${providerName} provider is unavailable.` @@ -49,6 +63,17 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
+ {canReauthenticate && reauthentication ? ( + + ) : null} ); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 51bfb62667d..8a768c83c8f 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,15 +1,25 @@ import { memo } from "react"; import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; -import { CircleAlertIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, KeyRoundIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, + onReauthenticate, + reauthenticateLabel, }: { error: string | null; onDismiss?: () => void; + /** + * When provided, renders a "Re-authenticate" action alongside the error. + * The caller decides when to offer it — typically when the error looks like + * an expired/failed provider credential and the active provider advertises + * an in-app re-authentication command. + */ + onReauthenticate?: () => void; + reauthenticateLabel?: string; }) { if (!error) return null; return ( @@ -24,11 +34,19 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ - {onDismiss && ( + {(onReauthenticate || onDismiss) && ( - + {onReauthenticate && ( + + )} + {onDismiss && ( + + )} )} diff --git a/docs/providers/claude.md b/docs/providers/claude.md index bbf72722cf1..071774fe277 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -29,6 +29,32 @@ Claude HOME path: empty An empty `Claude HOME path` means T3 Code uses your normal home directory. +## My Claude Login Expired + +If Claude Code's credential expires or is rejected, a turn fails with an error +such as: + +```text +Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue. +``` + +```text +Failed to authenticate. API Error: 401 Invalid authentication credentials +``` + +You do not need to leave T3 Code. T3 Code recognizes these authentication +failures automatically and offers a **Re-authenticate** button on the error +banner (and on the provider's status banner when it reports "unauthenticated"). +Clicking it opens an integrated terminal and runs `claude setup-token` for that +provider, +using its configured binary and Claude HOME. Follow the prompt — open the +printed URL, approve access, and paste the authorization code back into the +terminal. Once it finishes, retry your turn. + +The re-authenticate action runs against the same Claude provider you were using, +so multi-account and custom-home setups (below) re-authenticate the correct +account. + ## I Want Work And Personal Claude Accounts Use a different Claude home for each account. diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c906f86f4dc..af2581f4dc7 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -25,6 +25,60 @@ describe("ServerProvider", () => { expect(parsed.skills).toEqual([]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); + expect(parsed.reauthentication).toBeUndefined(); + }); + + it("decodes an in-app re-authentication descriptor", () => { + const parsed = decodeServerProvider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + version: "2.1.169", + status: "error", + auth: { + status: "unauthenticated", + }, + reauthentication: { + command: "claude setup-token", + executable: "claude", + args: ["setup-token"], + label: "Re-authenticate Claude", + env: { CLAUDE_CONFIG_DIR: "/home/dev/.claude_work" }, + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.reauthentication?.command).toBe("claude setup-token"); + expect(parsed.reauthentication?.executable).toBe("claude"); + expect(parsed.reauthentication?.args).toEqual(["setup-token"]); + expect(parsed.reauthentication?.label).toBe("Re-authenticate Claude"); + expect(parsed.reauthentication?.env).toEqual({ + CLAUDE_CONFIG_DIR: "/home/dev/.claude_work", + }); + }); + + it("defaults re-authentication args when omitted", () => { + const parsed = decodeServerProvider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + version: "2.1.169", + status: "ready", + auth: { + status: "authenticated", + }, + reauthentication: { + command: "claude setup-token", + executable: "claude", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.reauthentication?.args).toEqual([]); }); it("defaults one-click update support when decoding older advisory snapshots", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d99b8e95a6..70b858e1458 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -58,6 +58,33 @@ export const ServerProviderAuth = Schema.Struct({ }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; +/** + * Describes how a provider can be (re-)authenticated from within T3 Code. + * + * When present, the client may offer an in-app "Re-authenticate" action that + * runs `command` — an interactive CLI login such as `claude setup-token` or + * `codex login` — inside an integrated terminal using the provider's resolved + * environment. This lets users recover from an expired credential (e.g. a + * Claude `401 OAuth access token has expired` turn failure) without dropping + * to an external shell. `executable`/`args` carry the same command already + * split for callers that spawn directly instead of through a shell. + */ +export const ServerProviderReauthentication = Schema.Struct({ + command: TrimmedNonEmptyString, + executable: TrimmedNonEmptyString, + args: Schema.Array(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + label: Schema.optional(TrimmedNonEmptyString), + /** + * Environment overrides the login command must run with so it targets the + * same account/config as the provider instance — e.g. `CLAUDE_CONFIG_DIR` + * for a Claude instance with a custom home. Only non-secret isolation + * variables belong here; the client layers them onto the terminal + * environment when launching the command. + */ + env: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.String)), +}); +export type ServerProviderReauthentication = typeof ServerProviderReauthentication.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -172,6 +199,10 @@ export const ServerProvider = Schema.Struct({ version: Schema.NullOr(TrimmedNonEmptyString), status: ServerProviderState, auth: ServerProviderAuth, + // Present when the provider exposes an in-app re-authentication command + // (see `ServerProviderReauthentication`). Optional/back-compat: legacy + // producers omit it and consumers simply hide the re-authenticate action. + reauthentication: Schema.optionalKey(ServerProviderReauthentication), checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), // Optional for back-compat: every legacy producer omits this field and