Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
06dea8a
feat(provider): re-authenticate Claude from within T3 Code
sideeffffect Jul 19, 2026
c5276be
fix(provider): address review on in-app Claude re-authentication
sideeffffect Jul 19, 2026
b11de81
fix(provider): don't shell-quote the Claude re-auth command && Revert…
sideeffffect Jul 19, 2026
464b68d
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Jul 21, 2026
530ad24
fix(provider): expose re-auth on installed Claude health-check failur…
sideeffffect Jul 21, 2026
43aa145
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Jul 22, 2026
1f94d11
feat(web): auto-detect Claude "Invalid authentication credentials" fo…
sideeffffect Jul 22, 2026
29e60f2
fix(provider): scope Claude re-auth to first-party OAuth instances
sideeffffect Jul 22, 2026
4f95ba0
fix(provider): treat no/off/false backend flags as disabled && isTrut…
sideeffffect Jul 22, 2026
3bdab9c
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Jul 30, 2026
1077300
fix(provider): use `claude auth login` for in-app re-authentication
sideeffffect Jul 30, 2026
d60a62b
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Jul 30, 2026
58a33e1
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Aug 25, 2026
51b965a
fix(web): size re-auth banner buttons as xs to match Alert action slot
sideeffffect Aug 25, 2026
9ba4558
fix(web): suppress re-auth when the failed turn's provider instance i…
sideeffffect Aug 25, 2026
f2cdf0b
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Aug 26, 2026
a3dde40
Merge remote-tracking branch 'upstream/main' into feat/claude-reauth
sideeffffect Aug 31, 2026
b54af62
fix(web): don't offer Claude re-auth for API-key errors
sideeffffect Sep 1, 2026
8cc1872
Merge upstream/main into feat/claude-reauth
sideeffffect Sep 2, 2026
ea1840b
fix(web): don't offer Claude re-auth for a generic 'invalid token'
sideeffffect Sep 2, 2026
35d5261
fix(web): match Claude 'invalid bearer token' for re-auth without ove…
sideeffffect Sep 2, 2026
baa8585
Merge upstream/main into feat/claude-reauth
sideeffffect Sep 3, 2026
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
32 changes: 32 additions & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type ModelSelection,
ProviderDriverKind,
type ServerProviderModel,
type ServerProviderReauthentication,
type ServerProviderSlashCommand,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
claudeSettings: ClaudeSettings,
): ServerProviderReauthentication {
const executable = claudeSettings.binaryPath?.trim() || "claude";
const args = [...CLAUDE_REAUTHENTICATION_ARGS];
return {
command: [executable, ...args].join(" "),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
executable,
args,
label: "Re-authenticate Claude",
};
}

function toTitleCaseWords(value: string): string {
const parts: Array<string> = [];
for (const part of value.split(/[\s_-]+/g)) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -784,6 +814,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
reauthentication,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -804,6 +835,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 Down
6 changes: 6 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
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 @@ -216,6 +217,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 @@ -244,6 +246,7 @@ export function buildServerProvider(input: {
models: input.models,
slashCommands: [...(input.slashCommands ?? [])],
skills: [...(input.skills ?? [])],
...(input.reauthentication ? { reauthentication: input.reauthentication } : {}),
...(versionAdvisory ? { versionAdvisory } : {}),
};
}
Expand Down
62 changes: 58 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 @@ -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,
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -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 },
);
Comment thread
cursor[bot] marked this conversation as resolved.
},
[runProjectScript],
);

const persistProjectScripts = useCallback(
async (input: {
projectId: ProjectId;
Expand Down Expand Up @@ -5248,11 +5284,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 = activeProviderStatus?.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
31 changes: 28 additions & 3 deletions apps/web/src/components/chat/ProviderStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
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;
}

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.`
Expand Down Expand Up @@ -49,6 +63,17 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
</TooltipPopup>
</Tooltip>
</div>
{canReauthenticate && reauthentication ? (
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onReauthenticate?.(reauthentication)}
>
<KeyRoundIcon className="size-3.5" aria-hidden />
{reauthentication.label ?? "Re-authenticate"}
</Button>
Comment thread
sideeffffect marked this conversation as resolved.
) : null}
</div>
</div>
);
Expand Down
28 changes: 23 additions & 5 deletions apps/web/src/components/chat/ThreadErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -24,11 +34,19 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
</TooltipPopup>
</Tooltip>
</AlertDescription>
{onDismiss && (
{(onReauthenticate || onDismiss) && (
<AlertAction>
<Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
<XIcon className="text-destructive" />
</Button>
{onReauthenticate && (
<Button variant="outline" size="sm" onClick={onReauthenticate}>
<KeyRoundIcon className="size-3.5" aria-hidden />
{reauthenticateLabel ?? "Re-authenticate"}
</Button>
Comment thread
sideeffffect marked this conversation as resolved.
Outdated
)}
{onDismiss && (
<Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
<XIcon className="text-destructive" />
</Button>
)}
</AlertAction>
)}
</Alert>
Expand Down
19 changes: 19 additions & 0 deletions docs/providers/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions packages/contracts/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading