Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/lib/proxy-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export function outboundProxyConfigured(
return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
}

// Captured before config/proxy-env applies runtime changes; Bun retains startup proxy env.
export const startupOutboundProxyConfigured = outboundProxyConfigured();

/**
* The value when `raw` is a proxy URL Bun fetch can actually use, else null.
* Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
Expand Down
29 changes: 27 additions & 2 deletions src/oauth/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OAuthCallbackFlow } from "./callback-server";
import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity";
import { generatePKCE } from "./pkce";
import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
import { outboundProxyConfigured, startupOutboundProxyConfigured } from "../lib/proxy-env";

const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
Expand Down Expand Up @@ -40,19 +41,43 @@ export class AnthropicTokenError extends Error {
message: string,
readonly httpStatus: number | undefined,
readonly oauthError: string | undefined,
readonly requestNotSent = false,
) {
super(message);
this.name = "AnthropicTokenError";
}
}

async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
const response = await fetch(url, {
const direct = !startupOutboundProxyConfigured && !outboundProxyConfigured();
const init: RequestInit & { protocol: "http1.1" } = {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
// Pinned Bun 1.4.0: one HTTP/1.1 attempt, no keep-alive reuse or redirect following.
// Only without an outbound proxy can token-host DNS failure prove no request was sent.
redirect: "manual",
keepalive: false,
protocol: "http1.1",
};
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
if (direct && typeof error === "object" && error !== null
&& "code" in error && error.code === "ENOTFOUND"
&& "syscall" in error && error.syscall === "getaddrinfo"
&& "hostname" in error && error.hostname === new URL(url).hostname) {
throw new AnthropicTokenError("Anthropic OAuth DNS lookup failed before connection", undefined, undefined, true);
}
throw error;
}
if (response.status >= 300 && response.status < 400) {
// A redirect can follow a completed POST and token rotation; it is not a rejection.
await response.body?.cancel().catch(() => {});
throw new AnthropicTokenError(`Anthropic OAuth redirect HTTP ${response.status}: outcome unknown`, undefined, undefined);
}
const responseBody = await response.text();
if (!response.ok) {
let oauthError: string | undefined;
Expand Down
14 changes: 6 additions & 8 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,18 +1032,16 @@ export async function refreshAnthropicAccountWithLock(
// OAuthLoginRequiredError. One 503 locked the account out of refresh until manual
// re-auth even after upstream recovered.
//
// Only clear the intent when the server DEFINITIVELY answered and rejected the
// request. The adapter attaches an HTTP status only to that explicit non-success
// response. A timeout, a dropped connection, or an unreadable/unparseable body
// carries no status: the server may already have
// rotated the token, and replaying it could trip refresh-token-reuse revocation.
// Those outcomes keep the intent so the guard still refuses a blind replay.
if ((!refreshMayHaveReachedProvider || definitivelyAnswered(error)) && attemptIntent) {
// Clear only for proven pre-dispatch failure or an explicit HTTP rejection.
// Other transport/body failures may follow rotation and keep the replay guard.
const requestNotSent = !refreshMayHaveReachedProvider
|| (error instanceof AnthropicTokenError && error.requestNotSent);
if ((requestNotSent || definitivelyAnswered(error)) && attemptIntent) {
await clearAnthropicRefreshIntentForKnownFailure(
provider,
accountId,
attemptIntent,
refreshMayHaveReachedProvider ? "definitive-rejection" : "pre-dispatch",
requestNotSent ? "pre-dispatch" : "definitive-rejection",
error,
);
}
Expand Down
5 changes: 5 additions & 0 deletions structure/providers/anthropic-account-pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ serialized persistence. Superseding writes win. An unresolved full rotation leav
pending intent intact, without replaying a possibly consumed refresh or setting reauthentication
solely from the identity failure. A proven different account may use its own stored refresh only
when no pending intent blocks it. Intent cleanup follows successful durable adoption.
An unsent token request proven by structured `getaddrinfo` `ENOTFOUND` for the token host,
with no outbound proxy configured in either the startup or current environment and a single
HTTP/1.1 attempt without keep-alive reuse or redirect following, releases its intent as
`pre-dispatch`; redirects and every other transport failure keep the intent.
`tests/oauth/oauth-refresh.test.ts` covers this boundary.
An already-expired identityless row whose old bearer no longer authenticates cannot establish
continuity to a fully rotated pair automatically; explicit import can create a separate slot.

Expand Down
3 changes: 3 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,9 @@ admission or account-snapshot pairing. The forwarding contract is covered in

`src/config/proxy-env.ts` activates configured SOCKS5 through `src/lib/proxy-env.ts`;
the compatibility config facade does not own a second activation path.
The library captures `startupOutboundProxyConfigured` at module evaluation, before config
mutates proxy environment keys. Anthropic token-host DNS failure proves an unsent request
only when both this immutable startup snapshot and the current environment are proxy-free.
`src/server/responses/fetch-helpers.ts` routes the built-in HTTP executor through
configured outbound fetch, preserving physical-send admission and dispatch override.
Native WebSocket selection stays on HTTP SSE while SOCKS5 is configured.
Expand Down
138 changes: 138 additions & 0 deletions tests/oauth/oauth-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import * as storeModule from "../../src/oauth/store";
import * as configModule from "../../src/config";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { repoRoot } from "../helpers/repo-root";

const origHome = process.env.HOME;
const origLocalAppData = process.env.LOCALAPPDATA;
Expand Down Expand Up @@ -588,6 +589,143 @@ describe("oauth refresh hardening", () => {
expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pending);
});

describe("Anthropic real-adapter transport intent", () => {
const proxyKeys = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"];
const dns = { code: "ENOTFOUND", syscall: "getaddrinfo", hostname: "api.anthropic.com" };
const scenarios: Array<{
name: string; outcome: "retry" | "blocked" | "reauth";
first: () => Promise<Response>; proxy?: string; redirectStatus?: number;
}> = [
{ name: "HTTP 503", outcome: "retry", first: async () => new Response("unavailable", { status: 503 }) },
...[302, 303].map(status => ({ name: `manual redirect ${status}`, outcome: "blocked" as const, redirectStatus: status,
first: async () => new Response("redirect body must stay private", {
status, headers: { Location: "https://other.invalid/token" },
}),
})),
{ name: "timeout", outcome: "blocked", first: async () => { throw new DOMException("timeout", "TimeoutError"); } },
{ name: "invalid_grant", outcome: "reauth", first: async () => Response.json({ error: "invalid_grant" }, { status: 400 }) },
{ name: "wrong-host DNS", outcome: "blocked", first: async () => { throw { ...dns, hostname: "other.invalid" }; } },
{ name: "unstructured DNS text", outcome: "blocked", first: async () => { throw new Error("getaddrinfo ENOTFOUND api.anthropic.com"); } },
{ name: "DNS-shaped body-read failure", outcome: "blocked", first: async () => new Response(new ReadableStream({
start(controller) { controller.error(dns); },
})) },
{ name: "proxy-configured token-host DNS", outcome: "blocked", proxy: "http://proxy.invalid:8080", first: async () => { throw dns; } },
];
for (const scenario of scenarios) test(scenario.name, async () => {
const savedProxyEnv = proxyKeys.map(key => [key, process.env[key]] as const);
try {
for (const key of proxyKeys) delete process.env[key];
if (scenario.proxy) process.env.HTTPS_PROXY = scenario.proxy;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
const id = getAccountSet("anthropic")!.activeAccountId;
const credential = getAccountCredential("anthropic", id)!;
const tokenInits: RequestInit[] = [];
globalThis.fetch = (async (input, init) => {
if (String(input) === "https://api.anthropic.com/api/oauth/profile") {
return Response.json({ account: { uuid: "synthetic-account-a" } });
}
expect(String(input)).toBe("https://api.anthropic.com/v1/oauth/token");
tokenInits.push(init!);
if (tokenInits.length === 1) return scenario.first();
return Response.json({ access_token: "fresh", refresh_token: "rt-fresh", expires_in: 3600 });
}) as typeof fetch;
const refresh = () => refreshAnthropicAccountWithLock("anthropic", id, OAUTH_PROVIDERS.anthropic!, credential);
if (scenario.outcome === "reauth") await expect(refresh()).rejects.toBeInstanceOf(OAuthLoginRequiredError);
else if (scenario.redirectStatus) {
await expect(refresh()).rejects.toMatchObject({
name: "AnthropicTokenError", httpStatus: undefined, oauthError: undefined, requestNotSent: false,
message: `Anthropic OAuth redirect HTTP ${scenario.redirectStatus}: outcome unknown`,
});
} else await expect(refresh()).rejects.toBeDefined();
const pending = readOAuthRefreshIntent("anthropic", id);
expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBe(scenario.outcome === "reauth" ? true : undefined);
if (scenario.outcome === "blocked") {
expect(pending).toMatchObject({ generation: credentialGeneration(credential) });
expect(pending?.cleanupPending).toBeUndefined();
} else expect(pending).toBeUndefined();
if (scenario.outcome === "retry") {
await expect(refresh()).resolves.toBe("fresh");
expect(getAccountCredential("anthropic", id)?.refresh).toBe("rt-fresh");
expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined();
expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined();
} else {
await expect(refresh()).rejects.toBeInstanceOf(OAuthLoginRequiredError);
expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pending);
}
expect(tokenInits).toHaveLength(scenario.outcome === "retry" ? 2 : 1);
for (const init of tokenInits) expect(init).toMatchObject({ redirect: "manual", keepalive: false, protocol: "http1.1" });
} finally {
for (const [key, value] of savedProxyEnv) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
});

// The startup proxy snapshot is taken at module evaluation, so each startup environment
// runs in its own child process instead of depending on how this runner was launched.
const childPrelude = `
import assert from "node:assert/strict";
import { OAUTH_PROVIDERS, OAuthLoginRequiredError, refreshAnthropicAccountWithLock } from "./src/oauth";
import { credentialGeneration, getAccountCredential, getAccountSet, readOAuthRefreshIntent, saveCredential } from "./src/oauth/store";
import { outboundProxyConfigured, startupOutboundProxyConfigured } from "./src/lib/proxy-env";
const dns = ${JSON.stringify(dns)};
const inits = [];
globalThis.fetch = async (input, init) => {
if (String(input) === "https://api.anthropic.com/api/oauth/profile") {
return Response.json({ account: { uuid: "synthetic-account-a" } });
}
assert.equal(String(input), "https://api.anthropic.com/v1/oauth/token");
inits.push(init);
if (inits.length === 1) throw dns;
return Response.json({ access_token: "fresh", refresh_token: "rt-fresh", expires_in: 3600 });
};
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
const id = getAccountSet("anthropic").activeAccountId;
const credential = getAccountCredential("anthropic", id);
const refresh = () => refreshAnthropicAccountWithLock("anthropic", id, OAUTH_PROVIDERS.anthropic, credential);
`;
const runChild = async (startupProxy: string | undefined, body: string) => {
const env: Record<string, string | undefined> = { ...process.env };
for (const key of proxyKeys) delete env[key];
if (startupProxy !== undefined) env.HTTPS_PROXY = startupProxy;
const child = Bun.spawn([process.execPath, "--eval", `${childPrelude}${body}\nprocess.exit(0);`], {
cwd: repoRoot(), env, stdout: "pipe", stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" });
};

test("structured token-host DNS from a proxy-free startup", () => runChild(undefined, `
assert.equal(startupOutboundProxyConfigured, false);
assert.equal(outboundProxyConfigured(), false);
await assert.rejects(refresh, error => error.name === "AnthropicTokenError" && error.requestNotSent === true
&& error.httpStatus === undefined);
assert.equal(readOAuthRefreshIntent("anthropic", id), undefined);
assert.equal(getAccountSet("anthropic").accounts[0].needsReauth, undefined);
assert.equal(await refresh(), "fresh");
assert.equal(getAccountCredential("anthropic", id)?.refresh, "rt-fresh");
assert.equal(readOAuthRefreshIntent("anthropic", id), undefined);
assert.equal(getAccountSet("anthropic").accounts[0].needsReauth, undefined);
assert.equal(inits.length, 2);
for (const init of inits) assert.deepEqual([init.redirect, init.keepalive, init.protocol], ["manual", false, "http1.1"]);
`));

for (const current of [undefined, ""]) test(`startup proxy with current HTTPS_PROXY ${current === undefined ? "deleted" : "empty"}`, () => runChild("http://proxy.invalid:8080", `
for (const key of ${JSON.stringify(proxyKeys)}) delete process.env[key];
${current === undefined ? "" : 'process.env.HTTPS_PROXY = "";'}
assert.equal(startupOutboundProxyConfigured, true);
assert.equal(outboundProxyConfigured(), false);
await assert.rejects(refresh, error => error === dns);
const pending = readOAuthRefreshIntent("anthropic", id);
assert.equal(pending?.generation, credentialGeneration(credential));
assert.equal(pending?.cleanupPending, undefined);
assert.equal(getAccountSet("anthropic").accounts[0].needsReauth, undefined);
await assert.rejects(refresh, OAuthLoginRequiredError);
assert.deepEqual(readOAuthRefreshIntent("anthropic", id), pending);
assert.equal(inits.length, 1);
`));
});

test("a pre-dispatch Anthropic abort clears its unconsumed refresh intent", async () => {
await saveCredential("anthropic", { access: "old", refresh: "rt-old", expires: 1, accountId: "acct" });
const id = getAccountSet("anthropic")!.activeAccountId;
Expand Down
Loading