From 29b4d7cb1ac24bb1fbd55b5c707017b6c19c98fd Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 05:41:51 -0400 Subject: [PATCH 1/4] fix(server): a revoked Claude token no longer reads as authenticated A credential the CLI can find is not the same as a credential Anthropic still accepts, and the gap only shows up when a turn fails. Signed-off-by: Yordis Prieto --- .../provider/Drivers/ClaudeCredential.test.ts | 110 +++++++++++++++++ .../src/provider/Drivers/ClaudeCredential.ts | 75 +++++++++++ .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/Layers/ClaudeProvider.ts | 54 +++++++- .../provider/Layers/ProviderRegistry.test.ts | 116 ++++++++++++++++++ ...a-revoked-claude-token-reads-as-revoked.md | 42 +++++++ docs/fork/README.md | 2 + docs/internals/providers.md | 27 ++++ docs/user/providers-claude.md | 22 ++++ 9 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeCredential.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeCredential.ts create mode 100644 docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md 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..9f3bee3bb454 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts @@ -0,0 +1,110 @@ +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("reports a rejected token on 401", () => + Effect.gen(function* () { + const verdict = yield* verifyClaudeOAuthToken("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("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("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("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..f7eaea64dec2 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeCredential.ts @@ -0,0 +1,75 @@ +/** + * 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. + * + * @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"; +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: transport errors, timeouts, and every non-401 status collapse to + * `unknown` so the caller can leave the existing status alone. + */ +export const verifyClaudeOAuthToken = Effect.fn("verifyClaudeOAuthToken")(function* ( + token: string, +): Effect.fn.Return { + 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..ba1d4ebd0781 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,115 @@ 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: "stale-token" }, + ).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("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: "good-token" }, + ).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: "good-token" }, + ).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: "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..a2e18fd28343 --- /dev/null +++ b/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md @@ -0,0 +1,42 @@ +# 0017: A revoked Claude token reads as revoked + +- PR: [TrogonStack/t3code#PR](https://github.com/TrogonStack/t3code/pull/PR) +- 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 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..7d95cfc62057 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, [#PR](https://github.com/TrogonStack/t3code/pull/PR) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 910de3d695fa..9b7b980a29ac 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -105,6 +105,32 @@ 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. + +Three decisions carry the behavior: + +- 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 +198,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..2050b11babd6 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -215,3 +215,25 @@ 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 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. From a9f945a700f7d0887dd2dc94aaf18f84fba9442e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 05:42:12 -0400 Subject: [PATCH 2/4] docs(fork): link the ledger entry to its pull request Signed-off-by: Yordis Prieto --- docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md | 2 +- docs/fork/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index a2e18fd28343..4a5534e5103e 100644 --- a/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md +++ b/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md @@ -1,6 +1,6 @@ # 0017: A revoked Claude token reads as revoked -- PR: [TrogonStack/t3code#PR](https://github.com/TrogonStack/t3code/pull/PR) +- PR: [TrogonStack/t3code#28](https://github.com/TrogonStack/t3code/pull/28) - Status: active ## What you can do now diff --git a/docs/fork/README.md b/docs/fork/README.md index 7d95cfc62057..898941e21b4f 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -50,4 +50,4 @@ Each entry uses these sections: - **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, [#PR](https://github.com/TrogonStack/t3code/pull/PR) + active, [#28](https://github.com/TrogonStack/t3code/pull/28) From 792d6181975cc6d1ddf17faa23b2e0fff38f97dc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 14:58:52 -0400 Subject: [PATCH 3/4] fix(server): a value that is not a credential no longer reads as a rejected token Anthropic answers 401 to anything it does not recognise, and treating that as a verdict on the token blames the credential for a problem one layer up. Signed-off-by: Yordis Prieto --- .../provider/Drivers/ClaudeCredential.test.ts | 42 +++++++++++++++++-- .../src/provider/Drivers/ClaudeCredential.ts | 15 ++++++- .../provider/Layers/ProviderRegistry.test.ts | 41 ++++++++++++++++-- ...a-revoked-claude-token-reads-as-revoked.md | 3 ++ docs/internals/providers.md | 5 ++- docs/user/providers-claude.md | 4 ++ 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeCredential.test.ts b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts index 9f3bee3bb454..5c09dcc39260 100644 --- a/apps/server/src/provider/Drivers/ClaudeCredential.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts @@ -72,9 +72,43 @@ describe("verifyClaudeOAuthToken", () => { }), ); + 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("reports a rejected token on 401", () => Effect.gen(function* () { - const verdict = yield* verifyClaudeOAuthToken("stale").pipe( + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-stale").pipe( Effect.provide(httpClientLayer(() => new Response("unauthorized", { status: 401 }))), ); assert.strictEqual(verdict, "rejected"); @@ -83,7 +117,7 @@ describe("verifyClaudeOAuthToken", () => { it.effect("stays unknown when the account merely lacks scope", () => Effect.gen(function* () { - const verdict = yield* verifyClaudeOAuthToken("scoped-out").pipe( + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-scoped-out").pipe( Effect.provide(httpClientLayer(() => new Response("forbidden", { status: 403 }))), ); assert.strictEqual(verdict, "unknown"); @@ -92,7 +126,7 @@ describe("verifyClaudeOAuthToken", () => { it.effect("stays unknown when Anthropic is down", () => Effect.gen(function* () { - const verdict = yield* verifyClaudeOAuthToken("fine").pipe( + const verdict = yield* verifyClaudeOAuthToken("sk-ant-oat01-fine").pipe( Effect.provide(httpClientLayer(() => new Response("boom", { status: 503 }))), ); assert.strictEqual(verdict, "unknown"); @@ -101,7 +135,7 @@ describe("verifyClaudeOAuthToken", () => { it.effect("stays unknown when the request never leaves the machine", () => Effect.gen(function* () { - const verdict = yield* verifyClaudeOAuthToken("fine").pipe( + 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 index f7eaea64dec2..ae70d646bcc6 100644 --- a/apps/server/src/provider/Drivers/ClaudeCredential.ts +++ b/apps/server/src/provider/Drivers/ClaudeCredential.ts @@ -11,6 +11,11 @@ * 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"; @@ -28,6 +33,8 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; export type ClaudeCredentialVerdict = "live" | "rejected" | "unknown"; const ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models?limit=1"; +/** Shared by Anthropic setup tokens (`sk-ant-oat01-`) and API keys (`sk-ant-api03-`). */ +const ANTHROPIC_CREDENTIAL_PREFIX = "sk-ant-"; const ANTHROPIC_VERSION_HEADER = "2023-06-01"; const VERIFY_TIMEOUT = Duration.seconds(10); @@ -48,12 +55,16 @@ export function claudeOAuthTokenFromEnvironment( /** * Ask Anthropic whether it still accepts `token`. * - * Never fails: transport errors, timeouts, and every non-401 status collapse to - * `unknown` so the caller can leave the existing status alone. + * 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 (!token.startsWith(ANTHROPIC_CREDENTIAL_PREFIX)) { + return "unknown"; + } const client = yield* HttpClient.HttpClient; const request = HttpClientRequest.get(ANTHROPIC_MODELS_URL).pipe( HttpClientRequest.setHeader("authorization", `Bearer ${token}`), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ba1d4ebd0781..b404f1d29254 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2051,7 +2051,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", apiProvider: "firstParty", }), - { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "stale-token" }, + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-stale" }, ).pipe( Effect.provide(claudeCredentialHttpLayer(() => new Response("no", { status: 401 }))), ); @@ -2069,6 +2069,39 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + 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( @@ -2077,7 +2110,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", apiProvider: "firstParty", }), - { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "good-token" }, + { ...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"); @@ -2103,7 +2136,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te tokenSource: "CLAUDE_CODE_OAUTH_TOKEN", apiProvider: "firstParty", }), - { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "good-token" }, + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-good" }, ).pipe( Effect.provide( claudeCredentialHttpLayer(() => new Response("down", { status: 503 })), @@ -2128,7 +2161,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ tokenSource: "claude.ai", apiProvider: "firstParty" }), - { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "ignored-by-the-cli" }, + { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-ignored-by-the-cli" }, ).pipe( Effect.provide( claudeCredentialHttpLayer(() => { 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 index 4a5534e5103e..cb34827a17a2 100644 --- a/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md +++ b/docs/fork/0017-a-revoked-claude-token-reads-as-revoked.md @@ -9,6 +9,9 @@ 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. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 9b7b980a29ac..43a594f701d9 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -117,8 +117,11 @@ because it is the cheapest first-party endpoint that accepts the token and costs 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. -Three decisions carry the behavior: +Four decisions carry the behavior: +- Only a value carrying Anthropic's `sk-ant-` credential prefix is ever sent. 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. - 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 diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 2050b11babd6..06510a421831 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -231,6 +231,10 @@ 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. From 218eb1b3e7af444cd89d424e72cca9b4574edf48 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 20 Aug 2026 16:03:44 -0400 Subject: [PATCH 4/4] fix(server): a placeholder wearing the token prefix is not a token Signed-off-by: Yordis Prieto --- .../provider/Drivers/ClaudeCredential.test.ts | 16 ++++++++++++++++ .../src/provider/Drivers/ClaudeCredential.ts | 13 ++++++++++--- docs/internals/providers.md | 8 +++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeCredential.test.ts b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts index 5c09dcc39260..7c1c64b4c5a4 100644 --- a/apps/server/src/provider/Drivers/ClaudeCredential.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeCredential.test.ts @@ -106,6 +106,22 @@ describe("verifyClaudeOAuthToken", () => { }), ); + 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( diff --git a/apps/server/src/provider/Drivers/ClaudeCredential.ts b/apps/server/src/provider/Drivers/ClaudeCredential.ts index ae70d646bcc6..295add87aff5 100644 --- a/apps/server/src/provider/Drivers/ClaudeCredential.ts +++ b/apps/server/src/provider/Drivers/ClaudeCredential.ts @@ -33,8 +33,15 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; export type ClaudeCredentialVerdict = "live" | "rejected" | "unknown"; const ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models?limit=1"; -/** Shared by Anthropic setup tokens (`sk-ant-oat01-`) and API keys (`sk-ant-api03-`). */ -const ANTHROPIC_CREDENTIAL_PREFIX = "sk-ant-"; +/** + * `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); @@ -62,7 +69,7 @@ export function claudeOAuthTokenFromEnvironment( export const verifyClaudeOAuthToken = Effect.fn("verifyClaudeOAuthToken")(function* ( token: string, ): Effect.fn.Return { - if (!token.startsWith(ANTHROPIC_CREDENTIAL_PREFIX)) { + if (!ANTHROPIC_CREDENTIAL.test(token)) { return "unknown"; } const client = yield* HttpClient.HttpClient; diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 43a594f701d9..249ef4476381 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -119,9 +119,11 @@ provider health cadence and needs no cache of its own. Four decisions carry the behavior: -- Only a value carrying Anthropic's `sk-ant-` credential prefix is ever sent. 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. +- 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