Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Registry writes use atomic hard-link lock publication, so the filesystem contain
- Context is omitted from the patch map when unknown or equal to Claude Code's 200k default; `[1m]`-suffixed model ids and explicit context are mutually exclusive in the patch transforms.
- The per-site transforms in `patch-transforms.ts` (regexes, replacements, ordering, SKIP/FAIL semantics) are hard-won — change them only with byte-for-byte equivalence evidence on a real binary.

**Server** (`src/server/`): `index.ts` loads models from the registry (`loadServerModels`), `router.ts` handles `/anthropic` (Anthropic-format; passthrough for `modelFormat:'anthropic'` with `baseUrl`, SDK adapter for `'openai'`) and `/openai/v1` (OpenAI-format via `src/openai-adapter.ts`). Wizard/quick-start settings persist to config; network mode requires a password; default port 17645 (`--port` overrides). Endpoint-mode request model resolution (`createGatewayModelCatalog` in `server/models.ts`) accepts, in precedence order: exact catalog id (and its gateway-discovery id) → unmasked gateway id when `--mask-gateway-ids` is on (`vendor-mask.ts`) → canonical `clodex:{provider}:{model}` id → saved short aliases from `clodex models --alias` (the same alias table the proxy-mode MITM resolves) → 400. Aliases and canonical ids are accepted INPUT only — `/models` listings still advertise exactly the canonical/masked ids. Echo invariant: an aliased request's response `model` field echoes the alias verbatim (even under masking) so a patched Claude Code's context-window lookup keys match (`aliasNames` in `ServerOptions`).
**Server** (`src/server/`): `index.ts` loads models from the registry (`loadServerModels`), `router.ts` handles `/anthropic` (Anthropic-format; passthrough for `modelFormat:'anthropic'` with `baseUrl`, SDK adapter for `'openai'`) and `/openai/v1` (OpenAI-format via `src/openai-adapter.ts`). Wizard/quick-start settings persist to config; network mode requires a password; default port 17645 (`--port` overrides). Loopback binds reject non-loopback `Host` headers and non-loopback HTTP(S) origins before `/health`; loopback HTTP(S) and custom application origins such as Electron's `app://` remain accepted. Network mode skips these loopback guards and relies on its password. Endpoint-mode request model resolution (`createGatewayModelCatalog` in `server/models.ts`) accepts, in precedence order: exact catalog id (and its gateway-discovery id) → unmasked gateway id when `--mask-gateway-ids` is on (`vendor-mask.ts`) → canonical `clodex:{provider}:{model}` id → saved short aliases from `clodex models --alias` (the same alias table the proxy-mode MITM resolves) → 400. Aliases and canonical ids are accepted INPUT only — `/models` listings still advertise exactly the canonical/masked ids. Echo invariant: an aliased request's response `model` field echoes the alias verbatim (even under masking) so a patched Claude Code's context-window lookup keys match (`aliasNames` in `ServerOptions`).

**Env isolation** (`src/env.ts`): `buildChildEnv()` copies `process.env`, deletes conflicting `ANTHROPIC_*`/related vars, sets `ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY`/`ANTHROPIC_MODEL` for the child only. Claude Code may persist the model to `~/.claude/settings.json` itself; outside clodex's control (reset with `claude --model sonnet`).

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ ANTHROPIC_BASE_URL=http://127.0.0.1:17645/anthropic
OPENAI_BASE_URL=http://127.0.0.1:17645/openai/v1
```

Use any API key locally; network mode requires the server password. Proxy mode prints `HTTPS_PROXY`, `HTTP_PROXY`, `NODE_EXTRA_CA_CERTS`, and adjusted `NO_PROXY` / `no_proxy` values to export. The adjusted bypass lists preserve unrelated hosts while ensuring `api.anthropic.com` reaches the selective proxy. Do **not** set `ANTHROPIC_BASE_URL` in that mode.
Use any API key locally; network mode requires the server password. A loopback endpoint accepts CLI callers, loopback HTTP(S) origins, and application origins such as Electron's `app://`, but returns 403 for non-loopback `Host` headers or HTTP(S) browser origins. Proxy mode prints `HTTPS_PROXY`, `HTTP_PROXY`, `NODE_EXTRA_CA_CERTS`, and adjusted `NO_PROXY` / `no_proxy` values to export. The adjusted bypass lists preserve unrelated hosts while ensuring `api.anthropic.com` reaches the selective proxy. Do **not** set `ANTHROPIC_BASE_URL` in that mode.

Several `clodex server` instances can run at once — each advertises itself in `~/.clodex/server-runtime.json`, and `clodex-claude` prefers a proxy-mode server (newest first) when bridging (see [docs/background-agents.md](docs/background-agents.md)). Pass `--no-discovery` to keep a server out of that file, e.g. a dedicated endpoint you point another tool at.
Several `clodex server` instances can run at once — each advertises itself in `~/.clodex/server-runtime.json`, and `clodex-claude` prefers a proxy-mode server (newest first) when bridging (see [docs/background-agents.md](docs/background-agents.md)). Pass `--no-discovery` to keep a server out of that file, e.g. a dedicated endpoint for a compatible local tool.

Examples:

Expand Down
14 changes: 14 additions & 0 deletions src/oauth/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const ISSUER = 'https://auth.openai.com';
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000;
const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000;

async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Preserve the OAuth failure when transport cleanup also fails.
}
}

export interface OpenAiIdTokenClaims {
chatgpt_account_id?: string;
organizations?: Array<{ id: string }>;
Expand Down Expand Up @@ -46,8 +54,10 @@ export async function requestOpenAiDeviceCode(): Promise<OpenAiDeviceCodeData> {
'User-Agent': `clodex/${VERSION}`,
},
body: JSON.stringify({ client_id: CLIENT_ID }),
redirect: 'manual',
});
if (!response.ok) {
await cancelResponseBody(response);
throw new Error('Failed to initiate OpenAI device authorization');
}
return response.json() as Promise<OpenAiDeviceCodeData>;
Expand Down Expand Up @@ -77,6 +87,7 @@ export async function pollOpenAiDeviceCodeToken(
device_auth_id: deviceData.device_auth_id,
user_code: deviceData.user_code,
}),
redirect: 'manual',
});

if (response.ok) {
Expand All @@ -91,14 +102,17 @@ export async function pollOpenAiDeviceCodeToken(
client_id: CLIENT_ID,
code_verifier: data.code_verifier,
}).toString(),
redirect: 'manual',
});
if (!tokenResponse.ok) {
await cancelResponseBody(tokenResponse);
throw new Error(`OpenAI token exchange failed (${tokenResponse.status})`);
}
const tokens = await tokenResponse.json() as OAuthTokenResponse;
return { tokens, accountId: extractOpenAiAccountId(tokens) };
}

await cancelResponseBody(response);
if (response.status !== 403 && response.status !== 404) {
throw new Error(`OpenAI device authorization failed (${response.status})`);
}
Expand Down
3 changes: 3 additions & 0 deletions src/oauth/refresh-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export async function postOAuthRefresh(
...options.headers,
},
body: isJson ? JSON.stringify(body) : (body as URLSearchParams).toString(),
// Refresh tokens are carried in the request body, so never replay them to
// a redirect target.
redirect: 'manual',
});

if (!response.ok) {
Expand Down
7 changes: 6 additions & 1 deletion src/patch-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,12 @@ export function applyClodexPatches(source: string, config: PatchScriptModelConfi
function extendAliasArray(arrLiteral: string): string {
const toAdd = IDENTITIES.filter((a) => !new RegExp('"' + reEsc(a) + '"').test(arrLiteral));
if (toAdd.length === 0) return arrLiteral; // idempotent
return arrLiteral.replace(/\]\s*$/, ',' + toAdd.map(q).join(',') + ']');
// Function replacement, not a string one: a string replacement re-reads
// `$&`/`` $` ``/`$'` in the payload as insertion patterns, which would undo
// q()'s escaping. Identity aliases are validated upstream but the canonical
// model ids they are built from are not.
const addition = ',' + toAdd.map(q).join(',') + ']';
return arrLiteral.replace(/\]\s*$/, () => addition);
}

// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions src/registry/refresh-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ async function fetchJsonWithAuth(
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
// Match fetch-template-models/custom-endpoint: a bearer token is attached,
// so a redirect must fail rather than be chased to an unvetted host.
redirect: 'manual',
signal: controller.signal,
}).finally(() => clearTimeout(timer));
if (!response.ok) {
Expand Down
42 changes: 42 additions & 0 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ export function sanitizeCredential(value: string | null | undefined): string | n
return firstLine || null;
}

/**
* A loopback-bound gateway runs unauthenticated (local mode sets serverPassword
* to null), so it is reachable by any page the user happens to visit. The two
* guards below close that off without costing legitimate CLI callers anything:
*
* - Host: a browser cannot forge it. DNS rebinding — the trick that makes
* loopback responses *readable* cross-origin — pins Host to the attacker's
* own name, so requiring a loopback literal defeats it.
* - Origin: reject web pages hosted away from loopback, including CORS-simple
* text/plain POSTs that skip preflight. Loopback web apps and Electron clients
* with custom-scheme origins remain valid local callers.
*
* Network mode binds 0.0.0.0 and mandates a password, so it is gated already
* and its Host is legitimately a LAN address; both checks are skipped there.
*/
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);

export function isLoopbackBind(host: string | undefined): boolean {
return host === undefined || LOOPBACK_HOSTNAMES.has(host.toLowerCase());
}

export function isAllowedGatewayHost(hostHeader: string | null | undefined): boolean {
// HTTP/1.0 clients may omit Host entirely; browsers are incapable of it.
if (!hostHeader) return true;
try {
return LOOPBACK_HOSTNAMES.has(new URL(`http://${hostHeader}`).hostname.toLowerCase());
} catch {
return false;
}
}

export function isDisallowedGatewayOrigin(originHeader: string | null | undefined): boolean {
if (typeof originHeader !== 'string' || originHeader.trim() === '') return false;
try {
const origin = new URL(originHeader);
if (origin.protocol !== 'http:' && origin.protocol !== 'https:') return false;
return !LOOPBACK_HOSTNAMES.has(origin.hostname.toLowerCase());
} catch {
return false;
}
}

export function isAuthorized(request: Request, serverPassword: string | null): boolean {
if (serverPassword === null) return true;

Expand Down
21 changes: 20 additions & 1 deletion src/server/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { isAuthorized } from './auth.js';
import {
isAllowedGatewayHost,
isAuthorized,
isDisallowedGatewayOrigin,
isLoopbackBind,
} from './auth.js';
import {
formatGatewayAnthropicModels,
formatOpenAIModels,
Expand Down Expand Up @@ -204,6 +209,20 @@ async function routeRequest(req: IncomingMessage, res: ServerResponse, options:
const pathname = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`).pathname;
plog(`${req.method} ${pathname}`);

// Runs ahead of /health so that a rebound page cannot even probe for us.
if (isLoopbackBind(options.host)) {
if (!isAllowedGatewayHost(req.headers.host)) {
plog(`blocked non-loopback Host header: ${req.headers.host}`);
sendJson(res, 403, { error: { message: 'Forbidden: unexpected Host header' } });
return;
}
if (isDisallowedGatewayOrigin(req.headers.origin)) {
plog(`blocked non-loopback browser Origin: ${req.headers.origin}`);
sendJson(res, 403, { error: { message: 'Forbidden: non-loopback browser origins are not accepted' } });
return;
}
}

if (req.method === 'GET' && pathname === '/health') {
sendJson(res, 200, { ok: true });
return;
Expand Down
19 changes: 19 additions & 0 deletions src/upstream-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export async function relayAnthropicMessages(
options.extraHeaders,
),
body: JSON.stringify(body),
// Never auto-follow: anthropicUpstreamHeaders() attaches the credential as
// `x-api-key`, and the fetch spec only strips `authorization` on a
// cross-origin redirect — a custom header survives. Following a provider's
// 3xx would hand that key and the full request body to the redirect target.
redirect: 'manual',
signal: options.signal,
});

Expand All @@ -138,6 +143,20 @@ export async function relayAnthropicMessages(
throw new UpstreamUnreachableError(err);
}

if (upstreamRes.status >= 300 && upstreamRes.status < 400) {
const location = upstreamRes.headers.get('location') ?? '(none)';
try {
await upstreamRes.body?.cancel();
} catch {
// Preserve the redirect refusal when transport cleanup also fails.
}
options.log?.(`anthropic upstream refused redirect to ${location}`);
options.onUpstreamError?.(upstreamRes.status, `redirect to ${location}`);
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: 'Upstream attempted a redirect; refusing to forward credentials' } }));
return;
}

if (!upstreamRes.ok) {
const errBody = await upstreamRes.text();
options.log?.(`anthropic upstream ${upstreamRes.status}: ${errBody}`);
Expand Down
5 changes: 4 additions & 1 deletion tests/oauth-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('oauth/openai', () => {
expect(res.access_token).toBe('new_token');
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.openai.com/oauth/token',
expect.objectContaining({ method: 'POST' }),
expect.objectContaining({ method: 'POST', redirect: 'manual' }),
);
});

Expand Down Expand Up @@ -121,6 +121,9 @@ describe('oauth/openai', () => {
});
expect(sleep).toHaveBeenCalledWith(expect.any(Number)); // Called after the 403
expect(result.tokens.access_token).toBe('final_access_token');
for (const [, init] of vi.mocked(global.fetch).mock.calls) {
expect(init).toEqual(expect.objectContaining({ redirect: 'manual' }));
}
});

it('throws if device initiation fails', async () => {
Expand Down
17 changes: 11 additions & 6 deletions tests/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,13 @@ describe('oauth refresh http', () => {
vi.restoreAllMocks();
});

it('posts form refresh requests and includes response text in the error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
it('posts form refresh requests without following redirects and includes response text in the error', async () => {
const fetchMock = vi.fn(async () => ({
ok: false,
status: 401,
text: async () => 'bad refresh',
})));
}));
vi.stubGlobal('fetch', fetchMock);

await expect(postOAuthRefresh(
'https://auth/token',
Expand All @@ -69,14 +70,18 @@ describe('oauth refresh http', () => {
includeBody: true,
},
)).rejects.toThrow('xAI token refresh failed (401): bad refresh');
expect(fetchMock).toHaveBeenCalledWith(
'https://auth/token',
expect.objectContaining({ redirect: 'manual' }),
);
});

it('cancels an unread failed response body when error details are disabled', async () => {
it('rejects and cancels an unread redirect response', async () => {
const cancel = vi.fn(async () => {});
const text = vi.fn(async () => 'must stay unread');
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
status: 307,
body: { cancel },
text,
})));
Expand All @@ -89,7 +94,7 @@ describe('oauth refresh http', () => {
errorPrefix: 'token refresh failed',
includeStatus: true,
},
)).rejects.toThrow('token refresh failed (401)');
)).rejects.toThrow('token refresh failed (307)');
expect(cancel).toHaveBeenCalledOnce();
expect(text).not.toHaveBeenCalled();
});
Expand Down
14 changes: 14 additions & 0 deletions tests/patcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,20 @@ describe('patch script identity naming', () => {
'clodex:openai:mystery': { context: 128_000, display: 'Mystery (OpenAI)' },
};

it('treats $-sequences in a model id as literal text, not replacement patterns', () => {
// Aliases are validated, canonical ids are not. `$\`` expands to everything
// before the match when handed to String.replace as a *string*, which would
// splice the whole array prefix back into the literal and leave the patched
// bundle unparseable.
const out = runPatchScript({ 'clodex:openai:ev$`il': { context: 1000, display: 'Evil' } });

expect(out).toContain('"clodex:openai:ev$`il"');
expect(out).toContain('.enum(["sonnet","opus","haiku","fable","clodex:openai:ev$`il"])');
const knownList = /var KNOWN=(\[.*?\]);/.exec(out)?.[1];
expect(() => JSON.parse(knownList!)).not.toThrow();
expect(JSON.parse(knownList!)).toContain('clodex:openai:ev$`il');
});

it('injects the ALIAS — not the canonical id — as the model identity', () => {
const out = runPatchScript(config);

Expand Down
5 changes: 4 additions & 1 deletion tests/registry-refresh-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ describe('registry/refresh-models', () => {
const result = await refreshProviderModels('openai-oauth', 'mock_token', mockRegistry);

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('https://chatgpt.com/backend-api/codex/models?client_version='), expect.anything());
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('https://chatgpt.com/backend-api/codex/models?client_version='),
expect.objectContaining({ redirect: 'manual' }),
);

expect(result.ok).toBe(true);
expect(result.modelCount).toBe(1);
Expand Down
48 changes: 47 additions & 1 deletion tests/server-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { extractBearerToken, isAuthorized, sanitizeCredential } from '../src/server/auth.js';
import {
extractBearerToken,
isAllowedGatewayHost,
isAuthorized,
isDisallowedGatewayOrigin,
isLoopbackBind,
sanitizeCredential,
} from '../src/server/auth.js';

function request(headers: Record<string, string> = {}): Request {
return new Request('http://localhost/test', { headers });
Expand Down Expand Up @@ -32,3 +39,42 @@ describe('server auth', () => {
expect(isAuthorized(request({ authorization: 'Bearer secret' }), 'secret')).toBe(true);
});
});

describe('loopback gateway browser guards', () => {
it('recognises which binds are loopback', () => {
expect(isLoopbackBind('127.0.0.1')).toBe(true);
expect(isLoopbackBind('::1')).toBe(true);
expect(isLoopbackBind(undefined)).toBe(true); // fail closed
expect(isLoopbackBind('0.0.0.0')).toBe(false); // network mode is password-gated
});

it('accepts loopback Host headers with and without a port', () => {
for (const host of ['127.0.0.1:17645', 'localhost:17645', '[::1]:17645', '127.0.0.1', 'LocalHost']) {
expect(isAllowedGatewayHost(host), host).toBe(true);
}
expect(isAllowedGatewayHost(undefined)).toBe(true); // HTTP/1.0 has no Host
});

it('rejects the rebound Host headers a DNS-rebinding page is pinned to', () => {
for (const host of ['attacker.example:17645', 'clodex.attacker.test', '127.0.0.1.nip.io:17645', 'not a host']) {
expect(isAllowedGatewayHost(host), host).toBe(false);
}
});

it('rejects only non-loopback HTTP(S) origins', () => {
for (const origin of ['https://evil.example', 'http://attacker.test:17645']) {
expect(isDisallowedGatewayOrigin(origin), origin).toBe(true);
}
for (const origin of [
'http://localhost:17645',
'https://127.0.0.1',
'http://[::1]:17645',
'app://claude-desktop',
'null',
]) {
expect(isDisallowedGatewayOrigin(origin), origin).toBe(false);
}
expect(isDisallowedGatewayOrigin(undefined)).toBe(false);
expect(isDisallowedGatewayOrigin(' ')).toBe(false);
});
});
Loading
Loading