From 06dea8ae8761de476e50d8644424179ab5ada037 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sun, 19 Jul 2026 13:27:01 +0200 Subject: [PATCH 1/4] feat(provider): re-authenticate Claude from within T3 Code When Claude Code's OAuth token expires mid-turn ("Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue.") the only way to recover was to leave T3 Code and run the login command in an external shell. This adds an in-app re-authentication path. - contracts: add an optional ServerProviderReauthentication descriptor on ServerProvider (command / executable / args / label). Additive and back-compat: legacy producers omit it and consumers hide the action. - server: the Claude provider advertises "claude setup-token", resolved to the instance's configured binary, on its installed snapshots so custom binaries re-authenticate the same executable they run. - web: the thread error banner (on auth-looking turn failures) and the provider status banner (when a provider reports "unauthenticated") offer a "Re-authenticate" action that runs the login command in the thread's integrated terminal, reusing the proven project-script launcher. The user completes the interactive OAuth prompt (open URL, paste code) in-app. Docs (docs/providers/claude.md) and tests (contracts + provider snapshot) included. --- .../src/provider/Layers/ClaudeProvider.ts | 32 ++++++++++ .../provider/Layers/ProviderRegistry.test.ts | 6 ++ apps/server/src/provider/providerSnapshot.ts | 3 + apps/web/src/components/ChatView.tsx | 62 +++++++++++++++++-- .../components/chat/ProviderStatusBanner.tsx | 31 +++++++++- .../src/components/chat/ThreadErrorBanner.tsx | 28 +++++++-- docs/providers/claude.md | 19 ++++++ packages/contracts/src/server.test.ts | 50 +++++++++++++++ packages/contracts/src/server.ts | 23 +++++++ 9 files changed, 242 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b..a3871988e4e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,6 +4,7 @@ import { type ModelSelection, ProviderDriverKind, type ServerProviderModel, + type ServerProviderReauthentication, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -379,6 +380,34 @@ export function resolveClaudeApiModelId(modelSelection: ModelSelection): string } } +const CLAUDE_REAUTHENTICATION_ARGS = ["setup-token"] as const; + +/** + * Build the in-app re-authentication descriptor for a Claude provider + * instance. + * + * 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. + */ +export function resolveClaudeReauthentication( + claudeSettings: ClaudeSettings, +): ServerProviderReauthentication { + const executable = claudeSettings.binaryPath?.trim() || "claude"; + const args = [...CLAUDE_REAUTHENTICATION_ARGS]; + return { + command: [executable, ...args].join(" "), + executable, + args, + label: "Re-authenticate Claude", + }; +} + function toTitleCaseWords(value: string): string { const parts: Array = []; for (const part of value.split(/[\s_-]+/g)) { @@ -666,6 +695,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); + const reauthentication = resolveClaudeReauthentication(claudeSettings); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, PROVIDER, @@ -784,6 +814,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -804,6 +835,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + reauthentication, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf5..04a1939d499 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1475,6 +1475,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te 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) => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc44..8c39e721a8f 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, @@ -216,6 +217,7 @@ export function buildServerProvider(input: { models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; + reauthentication?: ServerProviderReauthentication; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -244,6 +246,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.tsx b/apps/web/src/components/ChatView.tsx index f175a21a131..93147680de8 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, @@ -265,6 +266,19 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; + +/** + * Heuristic for whether a thread/turn error stems from an expired or missing + * provider credential — the signals that make an in-app "Re-authenticate" + * action worth offering (e.g. Claude's + * `401 OAuth access token has expired. Re-authenticate to continue.`). + */ +function isProviderAuthError(message: string | null | undefined): boolean { + if (!message) return false; + return /(re-?authenticate|reauth|unauthenticated|not authenticated|authentication (failed|error|required)|oauth|access token|api key|\b401\b|\b403\b|log ?in again|sign ?in again)/i.test( + message, + ); +} const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -2702,6 +2716,28 @@ 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 }, + ); + }, + [runProjectScript], + ); + const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -5248,11 +5284,29 @@ function ChatViewContent(props: ChatViewProps) { {/* Error banner */} - - setThreadError(activeThread.id, null)} + + {(() => { + const reauth = activeProviderStatus?.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..4776c57a7b6 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -29,6 +29,25 @@ 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 OAuth token expires, a turn fails with an error such as: + +```text +Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue. +``` + +You do not need to leave T3 Code. Click **Re-authenticate** on the error banner +(or on the provider's status banner when it reports "unauthenticated"). T3 Code +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..e989687ca32 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -25,6 +25,56 @@ 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", + }, + 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"); + }); + + 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 b76ea965afe..fd58b0e094c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -58,6 +58,25 @@ 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), +}); +export type ServerProviderReauthentication = typeof ServerProviderReauthentication.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -171,6 +190,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 From c5276bede1b62773b44f5ca624c752c3bd13af03 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sun, 19 Jul 2026 13:43:36 +0200 Subject: [PATCH 2/4] fix(provider): address review on in-app Claude re-authentication Resolves the Cursor Bugbot / Macroscope review findings on #4151: - Propagate CLAUDE_CONFIG_DIR (High): the re-authentication descriptor now carries an optional `env` map, and the Claude provider fills it with the instance's resolved home (via resolveClaudeHomePath) when a custom home is configured. The web launcher layers it onto the terminal env, so `setup-token` refreshes the correct instance's credentials instead of the default config dir. Adds contract + provider tests. - Pending snapshot exposes re-auth (Medium): makePendingClaudeProvider now attaches the descriptor, so a turn failing before the first status check still offers the recovery action. - Target the failing instance (Medium): the thread error banner resolves the re-auth provider from the thread session's providerInstanceId (the one that produced lastError), not the composer's currently-selected provider. - Tighten auth-error heuristic (Medium): isProviderAuthError now matches credential-specific phrasing instead of bare 401/403/oauth/api-key substrings that also appear in generic tool/HTTP failures. - Shell-quote the command (Low): the login command line is POSIX-quoted so a configured binary path with spaces is not word-split when pasted. --- .../src/provider/Drivers/ClaudeDriver.ts | 5 +- .../src/provider/Layers/ClaudeProvider.ts | 52 +++++++++++++++---- .../provider/Layers/ProviderRegistry.test.ts | 27 ++++++++++ apps/web/src/components/ChatView.tsx | 33 ++++++++++-- packages/contracts/src/server.test.ts | 4 ++ packages/contracts/src/server.ts | 8 +++ 6 files changed, 114 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949c..de8f617223b 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).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 a3871988e4e..12f3a027f2a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -38,7 +38,7 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeEnvironment, resolveClaudeHomePath } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -382,6 +382,20 @@ export function resolveClaudeApiModelId(modelSelection: ModelSelection): string const CLAUDE_REAUTHENTICATION_ARGS = ["setup-token"] as const; +/** + * Quote a token for a POSIX shell (bash/zsh, the default integrated-terminal + * shells on macOS/Linux). Tokens made only of safe characters are left as-is + * so the common `claude setup-token` stays readable; anything else — e.g. a + * configured binary path containing spaces — is single-quoted so it is not + * word-split or interpreted when the client pastes the command line. + */ +function posixShellQuote(token: string): string { + if (token.length > 0 && /^[A-Za-z0-9_@%+=:,./-]+$/.test(token)) { + return token; + } + return `'${token.replaceAll("'", "'\\''")}'`; +} + /** * Build the in-app re-authentication descriptor for a Claude provider * instance. @@ -391,22 +405,33 @@ const CLAUDE_REAUTHENTICATION_ARGS = ["setup-token"] as const; * 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. + * 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 for instances with a custom home so `setup-token` refreshes the + * credentials of that exact instance rather than the default config dir + * (mirroring {@link makeClaudeEnvironment} used by every other Claude + * invocation). */ -export function resolveClaudeReauthentication( +export const resolveClaudeReauthentication = Effect.fn("resolveClaudeReauthentication")(function* ( claudeSettings: ClaudeSettings, -): ServerProviderReauthentication { +): Effect.fn.Return { const executable = claudeSettings.binaryPath?.trim() || "claude"; const args = [...CLAUDE_REAUTHENTICATION_ARGS]; + const command = [executable, ...args].map(posixShellQuote).join(" "); + const hasCustomHome = claudeSettings.homePath.trim().length > 0; + const env = hasCustomHome + ? { CLAUDE_CONFIG_DIR: yield* resolveClaudeHomePath(claudeSettings) } + : undefined; return { - command: [executable, ...args].join(" "), + command, executable, args, label: "Re-authenticate Claude", - }; -} + ...(env ? { env } : {}), + } satisfies ServerProviderReauthentication; +}); function toTitleCaseWords(value: string): string { const parts: Array = []; @@ -695,7 +720,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); - const reauthentication = resolveClaudeReauthentication(claudeSettings); + const reauthentication = yield* resolveClaudeReauthentication(claudeSettings); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, PROVIDER, @@ -854,7 +879,7 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( @@ -880,11 +905,16 @@ 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); 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 04a1939d499..7326e3b616e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1498,6 +1498,33 @@ 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("includes Claude Fable 5 on supported Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 93147680de8..25b93353612 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -272,10 +272,15 @@ const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; * provider credential — the signals that make an in-app "Re-authenticate" * action worth offering (e.g. Claude's * `401 OAuth access token has expired. Re-authenticate to continue.`). + * + * Intentionally narrow: it matches credential-specific phrasing rather than + * bare tokens like `401`/`oauth`/`api key`, which also appear in generic tool + * or HTTP failures and would otherwise surface a misleading re-authenticate + * action. */ function isProviderAuthError(message: string | null | undefined): boolean { if (!message) return false; - return /(re-?authenticate|reauth|unauthenticated|not authenticated|authentication (failed|error|required)|oauth|access token|api key|\b401\b|\b403\b|log ?in again|sign ?in again)/i.test( + return /(re-?authenticate|re-?auth\b|not (?:logged in|authenticated)|unauthenticated|authentication (?:failed|error|required)|access token (?:has )?expired|expired .*token|invalid (?:api key|access token|credentials)|please (?:log ?in|sign ?in)|(?:log|sign) ?in again)/i.test( message, ); } @@ -2303,6 +2308,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; @@ -2732,7 +2752,14 @@ function ChatViewContent(props: ChatViewProps) { icon: "configure", runOnWorktreeCreate: false, }, - { preferNewTerminal: true, rememberAsLastInvoked: 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], @@ -5289,7 +5316,7 @@ function ChatViewContent(props: ChatViewProps) { onReauthenticate={reauthenticateProvider} /> {(() => { - const reauth = activeProviderStatus?.reauthentication; + const reauth = threadErrorProviderStatus?.reauthentication; if (!reauth || !isProviderAuthError(threadError)) { return ( { 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: [], @@ -53,6 +54,9 @@ describe("ServerProvider", () => { 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", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index fd58b0e094c..0800131e131 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -74,6 +74,14 @@ export const ServerProviderReauthentication = Schema.Struct({ 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; From b11de81fe82b24ec6ad9cbd09838c43bd879f5c2 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sun, 19 Jul 2026 13:49:29 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(provider):=20don't=20shell-quote=20the?= =?UTF-8?q?=20Claude=20re-auth=20command=20&&=20Reverts=20the=20POSIX=20si?= =?UTF-8?q?ngle-quoting=20added=20in=20the=20previous=20commit:=20it=20bro?= =?UTF-8?q?ke=20&&=20other=20shells=20the=20integrated=20terminal=20may=20?= =?UTF-8?q?use=20=E2=80=94=20cmd.exe=20treats=20single=20quotes=20&&=20as?= =?UTF-8?q?=20literal=20(word-splitting=20a=20spaced=20path),=20and=20sing?= =?UTF-8?q?le=20quotes=20suppress=20~=20&&=20expansion=20in=20bash/zsh.=20?= =?UTF-8?q?No=20single=20pre-joined=20string=20is=20correct=20across=20all?= =?UTF-8?q?=20of=20&&=20them,=20so=20`command`=20is=20now=20a=20plain=20sp?= =?UTF-8?q?ace-join=20optimized=20for=20the=20common=20&&=20`claude`/PATH?= =?UTF-8?q?=20(and=20unquoted-path)=20case,=20with=20`executable`/`args`?= =?UTF-8?q?=20exposed=20&&=20separately=20for=20callers=20needing=20exact?= =?UTF-8?q?=20argv.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/provider/Layers/ClaudeProvider.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 12f3a027f2a..689eb88018d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -382,20 +382,6 @@ export function resolveClaudeApiModelId(modelSelection: ModelSelection): string const CLAUDE_REAUTHENTICATION_ARGS = ["setup-token"] as const; -/** - * Quote a token for a POSIX shell (bash/zsh, the default integrated-terminal - * shells on macOS/Linux). Tokens made only of safe characters are left as-is - * so the common `claude setup-token` stays readable; anything else — e.g. a - * configured binary path containing spaces — is single-quoted so it is not - * word-split or interpreted when the client pastes the command line. - */ -function posixShellQuote(token: string): string { - if (token.length > 0 && /^[A-Za-z0-9_@%+=:,./-]+$/.test(token)) { - return token; - } - return `'${token.replaceAll("'", "'\\''")}'`; -} - /** * Build the in-app re-authentication descriptor for a Claude provider * instance. @@ -413,13 +399,21 @@ function posixShellQuote(token: string): string { * credentials of that exact instance rather than the default config dir * (mirroring {@link makeClaudeEnvironment} used by every other Claude * invocation). + * + * `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, ): Effect.fn.Return { const executable = claudeSettings.binaryPath?.trim() || "claude"; const args = [...CLAUDE_REAUTHENTICATION_ARGS]; - const command = [executable, ...args].map(posixShellQuote).join(" "); + const command = [executable, ...args].join(" "); const hasCustomHome = claudeSettings.homePath.trim().length > 0; const env = hasCustomHome ? { CLAUDE_CONFIG_DIR: yield* resolveClaudeHomePath(claudeSettings) } From 530ad2430085b21b215593a7a7071fcc524ab7b3 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 21 Jul 2026 08:57:51 +0200 Subject: [PATCH 4/4] fix(provider): expose re-auth on installed Claude health-check failures && Addresses a Bugbot finding: checkClaudeProviderStatus omitted the && reauthentication descriptor on its installed error/timeout snapshots && (version-probe timeout, non-zero exit, and the installed branch of a && version-probe failure), so the in-app Re-authenticate action was hidden in && exactly the recoverable states where an expired credential is a likely && cause. Attach reauthentication to those installed:true snapshots; the && missing-binary case still omits it (a CLI that isn't installed can't be && re-authenticated). Extends the two existing error-path tests. --- apps/server/src/provider/Layers/ClaudeProvider.ts | 6 ++++++ apps/server/src/provider/Layers/ProviderRegistry.test.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f4096ebe894..460e1dfaecc 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -774,6 +774,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, @@ -792,6 +796,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + reauthentication, probe: { installed: true, version: null, @@ -816,6 +821,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + reauthentication, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 4b42b7ce79d..bb89cbbab4a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1947,6 +1947,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"))), ); @@ -1961,6 +1963,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te 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) => {