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).pipe(
Effect.map(stampIdentity),
Effect.provideService(Path.Path, path),
),
checkProvider,
enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
Expand Down
66 changes: 64 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 @@ -378,6 +379,53 @@ 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, 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).
*
* `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<ServerProviderReauthentication, never, Path.Path> {
const executable = claudeSettings.binaryPath?.trim() || "claude";
const args = [...CLAUDE_REAUTHENTICATION_ARGS];
const command = [executable, ...args].join(" ");
const hasCustomHome = claudeSettings.homePath.trim().length > 0;
const env = hasCustomHome
? { CLAUDE_CONFIG_DIR: yield* resolveClaudeHomePath(claudeSettings) }
: 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 @@ -687,6 +735,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
> {
const resolvedEnvironment = environment ?? process.env;
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const reauthentication = yield* resolveClaudeReauthentication(claudeSettings);
Comment thread
cursor[bot] marked this conversation as resolved.
const allModels = providerModelsFromSettings(
BUILT_IN_MODELS,
claudeSettings.customModels,
Expand Down Expand Up @@ -725,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,
Expand All @@ -743,6 +796,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: allModels,
reauthentication,
probe: {
installed: true,
version: null,
Expand All @@ -767,6 +821,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
enabled: claudeSettings.enabled,
checkedAt,
models: allModels,
reauthentication,
probe: {
installed: true,
version: parsedVersion,
Expand Down Expand Up @@ -803,6 +858,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
reauthentication,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -824,6 +880,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
reauthentication,
Comment thread
cursor[bot] marked this conversation as resolved.
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -842,7 +899,7 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

export const makePendingClaudeProvider = (
claudeSettings: ClaudeSettings,
): Effect.Effect<ServerProviderDraft> =>
): Effect.Effect<ServerProviderDraft, never, Path.Path> =>
Effect.gen(function* () {
const checkedAt = yield* nowIso;
const models = providerModelsFromSettings(
Expand All @@ -867,11 +924,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,
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,39 @@ 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) => {
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("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) => {
Expand Down Expand Up @@ -1914,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"))),
);

Expand All @@ -1928,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) => {
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;
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
89 changes: 85 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ProviderApprovalDecision,
ProviderInstanceId,
type ServerProvider,
type ServerProviderReauthentication,
type ResolvedKeybindingsConfig,
type ScopedThreadRef,
type ThreadId,
Expand Down Expand Up @@ -266,6 +267,24 @@ 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.`).
*
* 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|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,
);
Comment thread
cursor[bot] marked this conversation as resolved.
}
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -2290,6 +2309,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;
Expand Down Expand Up @@ -2703,6 +2737,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 } } : {}),
},
);
Comment thread
cursor[bot] marked this conversation as resolved.
},
[runProjectScript],
);

const persistProjectScripts = useCallback(
async (input: {
projectId: ProjectId;
Expand Down Expand Up @@ -5237,11 +5300,29 @@ function ChatViewContent(props: ChatViewProps) {
</header>

{/* Error banner */}
<ProviderStatusBanner status={activeProviderStatus} />
<ThreadErrorBanner
error={threadError}
onDismiss={() => setThreadError(activeThread.id, null)}
<ProviderStatusBanner
status={activeProviderStatus}
onReauthenticate={reauthenticateProvider}
/>
{(() => {
const reauth = threadErrorProviderStatus?.reauthentication;
if (!reauth || !isProviderAuthError(threadError)) {
return (
<ThreadErrorBanner
error={threadError}
onDismiss={() => setThreadError(activeThread.id, null)}
/>
);
}
return (
<ThreadErrorBanner
error={threadError}
onDismiss={() => setThreadError(activeThread.id, null)}
onReauthenticate={() => reauthenticateProvider(reauth)}
{...(reauth.label ? { reauthenticateLabel: reauth.label } : {})}
Comment thread
cursor[bot] marked this conversation as resolved.
/>
);
})()}
{/* Main content area with optional plan sidebar */}
<div className="flex min-h-0 min-w-0 flex-1">
{/* Chat column */}
Expand Down
Loading
Loading