diff --git a/apps/server/src/provider/Drivers/ClaudeCredential.test.ts b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts new file mode 100644 index 000000000000..7c1c64b4c5a4 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts @@ -0,0 +1,160 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { claudeOAuthTokenFromEnvironment, verifyClaudeOAuthToken } from "./ClaudeCredential.ts"; + +function httpClientLayer(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))), + ), + ); +} + +const transportFailureLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: new Error("offline") }), + }), + ), + ), +); + +describe("claudeOAuthTokenFromEnvironment", () => { + it("returns the configured token", () => { + assert.strictEqual( + claudeOAuthTokenFromEnvironment({ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-test" }), + "sk-ant-oat01-test", + ); + }); + + it("treats blank and missing values alike", () => { + assert.strictEqual(claudeOAuthTokenFromEnvironment({}), undefined); + assert.strictEqual( + claudeOAuthTokenFromEnvironment({ CLAUDE_CODE_OAUTH_TOKEN: " " }), + undefined, + ); + }); + + it("trims surrounding whitespace so a copy-pasted token still matches", () => { + assert.strictEqual( + claudeOAuthTokenFromEnvironment({ CLAUDE_CODE_OAUTH_TOKEN: " sk-ant-oat01-test\n" }), + "sk-ant-oat01-test", + ); + }); +}); + +describe("verifyClaudeOAuthToken", () => { + it.effect("reports a live token and sends bearer auth", () => + Effect.gen(function* () { + let seen: HttpClientRequest.HttpClientRequest | undefined; + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-test").pipe( + Effect.provide( + httpClientLayer((request) => { + seen = request; + return Response.json({ data: [] }); + }), + ), + ); + assert.strictEqual(verdict, "live"); + assert.strictEqual(seen?.headers["authorization"], "Bearer sk-ant-oat01-test"); + assert.strictEqual(seen?.headers["anthropic-version"], "2023-06-01"); + }), + ); + + it.effect("never asks Anthropic about an unresolved secret reference", () => + Effect.gen(function* () { + // Nothing resolved this into a credential, so a 401 would be Anthropic + // answering a question about a string that was never a token. + let requests = 0; + const verdict = yield* verifyClaudeOAuthToken("op://Vault/item/credential").pipe( + Effect.provide( + httpClientLayer(() => { + requests += 1; + return new Response("no", { status: 401 }); + }), + ), + ); + assert.strictEqual(verdict, "unknown"); + assert.strictEqual(requests, 0); + }), + ); + + it.effect("never asks Anthropic about a value that is not a credential", () => + Effect.gen(function* () { + let requests = 0; + const verdict = yield* verifyClaudeOAuthToken("${MY_TOKEN}").pipe( + Effect.provide( + httpClientLayer(() => { + requests += 1; + return new Response("no", { status: 401 }); + }), + ), + ); + assert.strictEqual(verdict, "unknown"); + assert.strictEqual(requests, 0); + }), + ); + + it.effect("never asks Anthropic about a placeholder wearing the credential prefix", () => + Effect.gen(function* () { + let requests = 0; + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-${MY_TOKEN}").pipe( + Effect.provide( + httpClientLayer(() => { + requests += 1; + return new Response("no", { status: 401 }); + }), + ), + ); + assert.strictEqual(verdict, "unknown"); + assert.strictEqual(requests, 0); + }), + ); + + it.effect("reports a rejected token on 401", () => + Effect.gen(function* () { + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-stale").pipe( + Effect.provide(httpClientLayer(() => new Response("unauthorized", { status: 401 }))), + ); + assert.strictEqual(verdict, "rejected"); + }), + ); + + it.effect("stays unknown when the account merely lacks scope", () => + Effect.gen(function* () { + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-scoped-out").pipe( + Effect.provide(httpClientLayer(() => new Response("forbidden", { status: 403 }))), + ); + assert.strictEqual(verdict, "unknown"); + }), + ); + + it.effect("stays unknown when Anthropic is down", () => + Effect.gen(function* () { + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-fine").pipe( + Effect.provide(httpClientLayer(() => new Response("boom", { status: 503 }))), + ); + assert.strictEqual(verdict, "unknown"); + }), + ); + + it.effect("stays unknown when the request never leaves the machine", () => + Effect.gen(function* () { + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-fine").pipe( + Effect.provide(transportFailureLayer), + ); + assert.strictEqual(verdict, "unknown"); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeCredential.ts b/apps/server/src/provider/Drivers/ClaudeCredential.ts new file mode 100644 index 000000000000..295add87aff5 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeCredential.ts @@ -0,0 +1,93 @@ +/** + * ClaudeCredential: liveness check for a configured Claude OAuth token. + * + * The capability probe reports which credential the CLI *found*, never whether + * Anthropic still honours it. An expired or revoked setup token still reads as + * `tokenSource: "CLAUDE_CODE_OAUTH_TOKEN"`, so Settings would keep claiming the + * provider is authenticated right up until a turn fails. This module closes + * that gap with one cheap authenticated GET. + * + * `GET /v1/models` is used because it is the only first-party endpoint that + * accepts the token, costs no message quota, and answers the only question + * worth asking: does Anthropic still accept this credential. + * + * Only a value that looks like an Anthropic credential is ever sent. Anything + * else is a question for someone other than Anthropic, and a `401` earned by an + * unresolved secret reference or a placeholder would blame the token for a + * problem that is not the token's. + * + * @module provider/Drivers/ClaudeCredential + */ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +/** + * Result of asking Anthropic about a token. + * + * `unknown` is the deliberate catch-all: a proxy, an outage, or a captive + * network must never sign a working install out of Settings, so only an + * explicit rejection is treated as one. + */ +export type ClaudeCredentialVerdict = "live" | "rejected" | "unknown"; + +const ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models?limit=1"; +/** + * `sk-ant-` is shared by Anthropic setup tokens (`sk-ant-oat01-`) and API keys + * (`sk-ant-api03-`). The tail is spelled out because the prefix alone accepts + * `sk-ant-oat01-${MY_TOKEN}`, and a placeholder that happens to carry the + * prefix is still a placeholder. Anything outside the character set key + * material is written with is treated as not a credential, which costs at most + * an unknown verdict and never a wrong one. + */ +const ANTHROPIC_CREDENTIAL = /^sk-ant-[A-Za-z0-9._~+/=-]+$/; +const ANTHROPIC_VERSION_HEADER = "2023-06-01"; +const VERIFY_TIMEOUT = Duration.seconds(10); + +/** + * The OAuth token a Claude instance was configured with, if any. + * + * Reads the same variable the CLI itself reads, so an instance whose + * environment carries an `op://` reference is checked with whatever that + * reference resolved to. + */ +export function claudeOAuthTokenFromEnvironment( + environment: NodeJS.ProcessEnv, +): string | undefined { + const token = environment["CLAUDE_CODE_OAUTH_TOKEN"]?.trim(); + return token ? token : undefined; +} + +/** + * Ask Anthropic whether it still accepts `token`. + * + * Never fails: a value that is not an Anthropic credential, transport errors, + * timeouts, and every non-401 status all collapse to `unknown` so the caller + * can leave the existing status alone. + */ +export const verifyClaudeOAuthToken = Effect.fn("verifyClaudeOAuthToken")(function* ( + token: string, +): Effect.fn.Return { + if (!ANTHROPIC_CREDENTIAL.test(token)) { + return "unknown"; + } + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get(ANTHROPIC_MODELS_URL).pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${token}`), + HttpClientRequest.setHeader("anthropic-version", ANTHROPIC_VERSION_HEADER), + HttpClientRequest.setHeader("accept", "application/json"), + ); + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(VERIFY_TIMEOUT), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(response)) { + return "unknown"; + } + const { status } = response.value; + if (status === 401) { + return "rejected"; + } + return status >= 200 && status < 300 ? "live" : "unknown"; +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 223624963f5e..ef85eb0d4853 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -177,6 +177,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b3a2c60e88d7..df226b38a697 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, @@ -39,6 +40,10 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { + claudeOAuthTokenFromEnvironment, + verifyClaudeOAuthToken, +} from "../Drivers/ClaudeCredential.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; @@ -608,6 +613,31 @@ export function claudeAuthStatus( : "unauthenticated"; } +/** `tokenSource` the SDK reports when the CLI took its token from the environment. */ +const CLAUDE_OAUTH_TOKEN_SOURCE = "CLAUDE_CODE_OAUTH_TOKEN"; + +/** + * Whether Anthropic has stopped accepting the token this instance was given. + * + * Gated on the CLI having actually chosen the environment token, so an install + * that authenticates some other way is never judged by a variable it ignores. + * Anything short of an outright rejection answers `false`: an offline machine + * should show a stale status, not a wrong one. + */ +const isConfiguredClaudeTokenRejected = Effect.fn("isConfiguredClaudeTokenRejected")(function* ( + tokenSource: string | undefined, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return { + if (tokenSource !== CLAUDE_OAUTH_TOKEN_SOURCE) { + return false; + } + const token = claudeOAuthTokenFromEnvironment(environment); + if (!token) { + return false; + } + return (yield* verifyClaudeOAuthToken(token)) === "rejected"; +}); + // ── SDK capability probe ──────────────────────────────────────────── // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the @@ -850,7 +880,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -1001,6 +1034,25 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + if (yield* isConfiguredClaudeTokenRejected(capabilities.tokenSource, resolvedEnvironment)) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unauthenticated" }, + message: + "Anthropic rejected this instance's CLAUDE_CODE_OAUTH_TOKEN. Generate a fresh token with `claude setup-token`.", + }, + }); + } + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6d4f8318f86f..b404f1d29254 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -78,6 +78,13 @@ const TestHttpClientLive = Layer.succeed( ), ); +function claudeCredentialHttpLayer(respond: () => Response) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, respond()))), + ); +} + const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, removeRpcClient: () => Effect.void, @@ -2034,6 +2041,148 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("signs out an instance whose OAuth token Anthropic rejects", () => + Effect.gen(function* () { + // The probe only proves the CLI found a token; Anthropic is the one + // that knows whether it still works. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", + apiProvider: "firstParty", + }), + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-stale" }, + ).pipe( + Effect.provide(claudeCredentialHttpLayer(() => new Response("no", { status: 401 }))), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.include(status.message ?? "", "CLAUDE_CODE_OAUTH_TOKEN"); + }).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("leaves the instance alone when the token is an unresolved reference", () => + Effect.gen(function* () { + // Nothing resolved the reference into a credential, so Anthropic has + // no opinion worth asking for and the token is not what is wrong. + let requests = 0; + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", + apiProvider: "firstParty", + }), + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "op://Vault/item/credential" }, + ).pipe( + Effect.provide( + claudeCredentialHttpLayer(() => { + requests += 1; + return new Response("no", { status: 401 }); + }), + ), + ); + assert.strictEqual(requests, 0); + assert.strictEqual(status.auth.status, "authenticated"); + }).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("stays authenticated while Anthropic still accepts the OAuth token", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", + apiProvider: "firstParty", + }), + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-good" }, + ).pipe(Effect.provide(claudeCredentialHttpLayer(() => Response.json({ data: [] })))); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).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( + "keeps a working instance authenticated when the check cannot reach Anthropic", + () => + Effect.gen(function* () { + // An outage or a captive network must never read as a revoked token. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", + apiProvider: "firstParty", + }), + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-good" }, + ).pipe( + Effect.provide( + claudeCredentialHttpLayer(() => new Response("down", { status: 503 })), + ), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).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("asks Anthropic nothing when the CLI authenticates some other way", () => + Effect.gen(function* () { + let requests = 0; + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "claude.ai", apiProvider: "firstParty" }), + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-ignored-by-the-cli" }, + ).pipe( + Effect.provide( + claudeCredentialHttpLayer(() => { + requests += 1; + return new Response("no", { status: 401 }); + }), + ), + ); + assert.strictEqual(requests, 0); + assert.strictEqual(status.auth.status, "authenticated"); + }).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("keeps an API key install authenticated when it reports no token source", () => Effect.gen(function* () { // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that diff --git a/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md b/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md new file mode 100644 index 000000000000..cb34827a17a2 --- /dev/null +++ b/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md @@ -0,0 +1,45 @@ +# 0017: A revoked Claude token reads as revoked + +- PR: [TrogonStack/t3code#28](https://github.com/TrogonStack/t3code/pull/28) +- Status: active + +## What you can do now + +- Trust the badge on a Claude instance that authenticates with a setup token. + If Anthropic has stopped accepting that token, Settings says so and tells you + to mint a new one, instead of reporting the instance as authenticated until + the first message you send it fails. +- Keep the status you had when the value is not a credential at all. A + placeholder, or a reference to a secret store that nothing resolved, leaves + the instance as it was instead of blaming a token that was never there. +- Keep the status you had when the network is the problem. An outage, a proxy, + or a captive portal leaves the instance exactly as it was, so a bad connection + never looks like a revoked credential. +- Keep trusting every other kind of Claude install. Instances signed in with + `claude auth login`, running on an API key, pointed at a router, or backed by + Bedrock or Vertex are reported exactly as before. + +## Why + +Settings answers one question: is this provider working. Entry 0015 taught it to +notice an install that holds no credential at all, but a credential that exists +and no longer works looked identical to one that does, and that is the more +common failure. Setup tokens expire on their own schedule and get revoked out +from under you. + +This matters most where several Claude accounts run side by side, each holding +its own token. That is exactly the setup where you consult the badge rather than +already knowing the answer, and a badge that is right about the accounts you were +not worried about while staying green on the one that just broke spends the trust +that makes the rest of the page worth reading. + +## Upstream considerations + +Nothing here is fork-specific and it belongs upstream, but it is a weaker +candidate than 0015 because it puts a direct Anthropic request in the provider +status path and relies on an authentication mode Anthropic has not published. +Both are reasons upstream might decline, so expect to carry it. + +The rebase burden is small and well contained: the request lives in its own +module, and the status check gains one branch after the decision entry 0015 +already introduced. A sync must not drop that branch. diff --git a/docs/fork/README.md b/docs/fork/README.md index 6ad790c74414..898941e21b4f 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -49,3 +49,5 @@ Each entry uses these sections: active, [#26](https://github.com/TrogonStack/t3code/pull/26) - **0016** [Provider secrets can live in 1Password](./0016-provider-secrets-live-in-1password.md) active, [#27](https://github.com/TrogonStack/t3code/pull/27) +- **0017** [A revoked Claude token reads as revoked](./0017-a-revoked-claude-token-reads-as-revoked.md) + active, [#28](https://github.com/TrogonStack/t3code/pull/28) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 910de3d695fa..249ef4476381 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -105,6 +105,37 @@ not one of them. It lives inside `makeManagedServerProvider` and calls `refreshS which is what keeps a resolved secret alive between refreshes instead of re-reading it every few minutes. +## Claude credential liveness + +The Claude capability probe reports which credential the CLI _found_, which is a different question +from whether Anthropic still honours it. A revoked or expired setup token still reports +`tokenSource: "CLAUDE_CODE_OAUTH_TOKEN"`, so Settings kept showing a green badge until the first +turn failed. + +[`ClaudeCredential.ts`][claudecred] closes that gap with one authenticated `GET /v1/models`, chosen +because it is the cheapest first-party endpoint that accepts the token and costs no message quota. +The status check runs it from the same path that already spawns the probe, so it inherits the +provider health cadence and needs no cache of its own. + +Four decisions carry the behavior: + +- Only a value shaped like an Anthropic credential is ever sent: the `sk-ant-` prefix followed by + key material and nothing else. A placeholder, or a secret reference nothing resolved, earns a + `401` that says nothing about the token, and acting on it would blame the credential for a + problem one layer up. The tail is checked because a placeholder can wear the prefix + (`sk-ant-oat01-${MY_TOKEN}`), and rejecting a real token by mistake only costs an `unknown`. +- Only an explicit `401` counts. Timeouts, transport errors, `403`, and every 5xx answer `unknown`, + because a proxy or an outage must never sign a working install out of Settings. +- The check only ever downgrades. It runs after the probe has already concluded the instance is + authenticated, so it can turn a green badge red but never the reverse. +- It is gated on the CLI reporting that it took its token from the environment. An install that + authenticates through a keychain login, an API key, a router, or a cloud backend is never judged + by a variable it ignores, even when that variable happens to be set. + +Note that Bearer authentication with a Claude Code OAuth token is not part of Anthropic's public API +surface. It is verified working, not contractually stable, which is the other reason every +unexpected answer is treated as `unknown`. + ## How provider work is requested Clients never call a provider directly. They dispatch orchestration commands over the RPC method @@ -172,6 +203,7 @@ indicator stops and the reason is visible in the timeline. [drivers]: ../../apps/server/src/provider/builtInDrivers.ts [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts +[claudecred]: ../../apps/server/src/provider/Drivers/ClaudeCredential.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index f9699388b7db..06510a421831 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -215,3 +215,29 @@ If the preset needs different Claude files, give it a different `CLAUDE_CONFIG_D different API keys, base URLs, or router settings, use Environment variables. Do not put environment variable assignments in `Launch arguments`. + +## Settings Says Anthropic Rejected My Token + +You see this on a provider whose Environment variables set `CLAUDE_CODE_OAUTH_TOKEN`. It means +T3 Code asked Anthropic about that token and Anthropic refused it, so the provider is marked not +authenticated before you spend a message finding out. + +Setup tokens expire and can be revoked. Mint a fresh one and paste it back into the provider's +Environment variables: + +```bash +claude setup-token +``` + +Then use Refresh provider status in Settings to check it again. + +A value that is not an Anthropic credential never causes this message either. T3 Code only asks +Anthropic about something that looks like one, so a placeholder or an unresolved reference leaves +the provider's status alone rather than blaming a token that was never there. + +A network problem never causes this message. If T3 Code cannot reach Anthropic at all, the +provider keeps whatever status it already had. + +This check only applies to providers that authenticate with `CLAUDE_CODE_OAUTH_TOKEN`. Providers +signed in with `claude auth login`, an API key, a router, or a cloud backend such as Bedrock or +Vertex are reported exactly as before.