diff --git a/.devflow/features/cli-ux/KNOWLEDGE.md b/.devflow/features/cli-ux/KNOWLEDGE.md index dab2ac4..0baf0a1 100644 --- a/.devflow/features/cli-ux/KNOWLEDGE.md +++ b/.devflow/features/cli-ux/KNOWLEDGE.md @@ -101,9 +101,9 @@ Key exports: `PROVIDER_IDS`, `ProviderId`, `AliasesByProvider`, `MODEL_REGISTRY` `parseCliArgs` returns a discriminated `CliCommand` union. Flag sets per command: - `serve`: `verbose`, `quiet`, `port` -- `doctor`: (none — any flag on `doctor` is an error) -- `models`: `json` only -- `init`: `yes`, `dry-run`, `port`, `settings-target` +- `doctor`: `client` (defaults to `all`; reverse checks run when configured) +- `models`: `json`, `client` (defaults to `claude-code`) +- `init`: `yes`, `dry-run`, `port`, `settings-target`, `client` (defaults to `claude-code`) Per-command flag validation walks `parseArgs` **tokens**, not `values`. The main switch is exhaustive (`default` assigns to `never` and calls `fail()`). @@ -328,3 +328,20 @@ Emits to stderr. Format: `[HH:MM:SS] level= event= key=value …`. Fields - PF-006: Doctor exits non-zero without live services; smoke uses `--version` not `doctor`; drives the `configuredProviders` severity split. - `.devflow/features/codex-leg/KNOWLEDGE.md` — Full model resolution contract, `buildHeaders`, `ProviderEvents

` 19-field table, and the Codex handler/translator/auth side. - `src/version.ts` — Source of `SUBSWITCH_VERSION` used by `--version`, doctor, `/__subswitch/health`, and `models --json`. + +## Bidirectional ingress and configuration (2026-09-08) + +`Config.codexIngress` enables native Codex HTTP/WebSocket ingress; its `claude` slice +controls Claude translation. `init --client codex|all` plans native TOML and SubSwitch +writes through `codex-init.ts` Result-returning helpers; custom upstream trust is explicit. +`doctor --client codex` checks native setup/auth/connectivity without refresh. Default +`doctor` selects `all` but skips disabled reverse checks. `models --json` stays version 1 +for `claude-code`; version 2 has `client: codex|all` and separately documented shapes. + +Configuration precedence is explicit `SUBSWITCH_CONFIG` (no merge), otherwise project +`subswitch.config.json` over `$XDG_CONFIG_HOME/subswitch/config.json` over defaults. +`LoadConfigResult.configPaths` lists every loaded source; legacy/unknown-provider errors +name the source containing the offending key. `configuredProviders` reflects all sources. + +`src/clients.ts` owns the supported-client IDs and the `all` selector. Legacy `both` +normalizes to `all`; model JSON uses the canonical `client: "all"` discriminator. diff --git a/.devflow/features/codex-leg/KNOWLEDGE.md b/.devflow/features/codex-leg/KNOWLEDGE.md index 95183af..67fb037 100644 --- a/.devflow/features/codex-leg/KNOWLEDGE.md +++ b/.devflow/features/codex-leg/KNOWLEDGE.md @@ -358,3 +358,34 @@ IncomingMessage (Anthropic wire) - PF-012: The mutation-proof pass needs its own controls - PF-013: The live Codex `/responses` stream sends no content-type header — the recorder cannot capture SSE without this - `.devflow/features/cli-ux/KNOWLEDGE.md` — CLI UX layer; `subswitch models` command; doctor agent-scan; N-provider fan-out; `ProviderEvents

` compile-time log-injection control + +## Native Codex → Claude ingress (2026-09-08) + +The forward leg described above remains the default. `Config.codexIngress` adds an +opt-in reverse leg, enabled by `init --client codex|all`. Claude models and aliases +resolve by exact membership; `decideCodexRoute` consumes a typed resolution for both +HTTP and WebSockets. `CodexGateway` wires `CodexUpstream`, `CodexWebSockets`, native auth, +and `ClaudeHandler`. Both HTTP directions use `createRawHttpForwarder`; only complete +native requests with substituted credentials may refresh and retry once after 401. + +Claude credential infrastructure is created in `buildDeps`. Native token substitution +is restricted to exact native endpoints and matching account IDs. `errors.ts` owns both +wire protocols' redaction. `claude-errors.ts` maps each failure code to an explicit status. +`ClaudeCache` shares the configured `codexIngress.claude.reasoningCache` budget across +continuation snapshots, thinking replay, and adaptation markers. Missing state returns +409. `content-encoding.ts` owns async codecs and the native zstd capability check. + +`claude-adapter.ts` translates complete histories; `claude-stream.ts` tracks explicit +stream phases and per-block state, then commits executable tools only at a valid terminal. +Collaboration namespace adaptation applies to OpenAI turns too when Claude routing is +enabled. All `/codex` paths are reserved even when disabled. Raw TCP connect budgets do +not impose TLS, HTTP-header, WebSocket-handshake, or established-stream deadlines. + +CLI `--client` defaults and merged configuration provenance are documented in the CLI UX +KB. Both directions use `allowInsecureBaseUrl` for explicit custom-host trust. Native +upgraded sockets keep a separate capacity slot until the client connection closes. + +Native cancellation keeps an empty, bounded replay placeholder until a valid terminal. +The ordered `` notice and readable partial history can then continue +without replaying unfinished thinking or tools. Missing relay-side Claude credentials +return 503 so native Codex does not refresh its unrelated OpenAI login. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0683962..edc87a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,20 +15,33 @@ concurrency: jobs: check: - name: check (${{ matrix.os }}) + name: ${{ matrix.check_name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + include: + # Preserve the names required by main's branch protection. + - os: ubuntu-latest + node: "22.15.0" + check_name: check (ubuntu-latest) + - os: macos-latest + node: "22.15.0" + check_name: check (macos-latest) + - os: ubuntu-latest + node: "24" + check_name: check (ubuntu-latest, Node 24) + - os: macos-latest + node: "24" + check_name: check (macos-latest, Node 24) steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Node 22 + - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: npm - name: Install dependencies diff --git a/.gitignore b/.gitignore index 41286ad..16d1094 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist/ !.devflow/features/*/ .devflow/features/*/* !.devflow/features/*/KNOWLEDGE.md +!.devflow/conventions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 553584a..8ff608e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- Opt-in native Codex → Claude routing for Sonnet, Opus and Fable, while OpenAI + models continue to OpenAI and Codex retains native agents, tools and permissions. +- Subscription authentication, credential refresh, readable collaboration messages, + streamed/non-stream responses, prompt caching and bounded process-local replay. +- `init`, `doctor` and `models --client codex|all`, native model discovery, and a + user configuration fallback. Existing Claude Code setup remains the default. +- Live native acceptance runners and shared parity follow-ups in issues #45–#48. + +- `--client all` selects all supported clients; `both` remains a compatibility alias. + +### Changed + +- Supported Node versions are `^22.15.0 || >=24`, matching native zstd and runtime dependency requirements. Reverse routing includes the + documented Claude identity preamble. Durable restart/compaction, setup undo and + explicit API authentication for translated inference remain separate shared work. + +- User-level configuration now participates in every implicit config load, including + forward-only runs. Project fields override it; explicit config selection bypasses it. + Invalid user configuration can therefore prevent startup; diagnostics show its source. +- Raw relay headers named by `Connection` are stripped on both legs in both directions. +- Native Codex setup requires explicit trust for a custom upstream host before it writes files. + +### Fixed + +- Native Codex conversations can continue after cancelling a Claude response; pending + replay handles and ordered cancellation notices no longer cause 409/400 failures. +- Missing relay-side Claude credentials produce Claude-specific setup guidance without + triggering native Codex to refresh its unrelated OpenAI login. Lost continuation + errors now explain that a new conversation is required. + +- Scope native credential substitution to exact Codex endpoints and redact synthesized + OpenAI errors at the JSON, SSE, and WebSocket render boundaries. +- Use async decompression, one shared continuation/replay budget, bounded upgraded + sockets, and the shared raw HTTP transport with one credential refresh retry. +- Classify translation, state, internal, and upstream failures explicitly; validate + malformed history/catalog values and bound recursive protocol traversal. +- Document all ingress configuration keys, model JSON schemas, and ingress log routes. + ## [0.4.0] - 2026-09-01 ### Changed diff --git a/README.md b/README.md index e21f6b2..ae4c4e7 100644 --- a/README.md +++ b/README.md @@ -2,52 +2,38 @@ [![CI](https://github.com/dean0x/subswitch/actions/workflows/ci.yml/badge.svg)](https://github.com/dean0x/subswitch/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Node 22+](https://img.shields.io/badge/node-22%2B-brightgreen.svg)](https://nodejs.org/) +[![Node 22.15+ or 24+](https://img.shields.io/badge/node-22.15%2B%20or%2024%2B-brightgreen.svg)](https://nodejs.org/) -**Route Claude Code subagents to a different model - keep everything else on Claude.** +**Route native sub-agents between Claude Code and Codex, using your subscriptions.** -subswitch is a local subscription-routing proxy for Claude Code. Give a subagent a -Codex model in its frontmatter (`model: sol`) and *that subagent alone* runs -on your **Codex subscription**; your main agent and every other request stay on -your **claude.ai subscription**, untouched. No API keys — subswitch forwards the -subscription credential each leg already uses. +SubSwitch is a local protocol bridge. Claude Code can delegate a sub-agent to an +OpenAI model; Codex can delegate a native sub-agent to a Claude model. Routing is +selected by the requested model. The originating client keeps its agents, tools, +permissions, and execution loop. -## Why this is different +## Model-based routing -Every other Claude Code proxy is all-or-nothing. `ANTHROPIC_BASE_URL` is a single -global setting, so existing routers point *all* of Claude Code's traffic at one -endpoint and typically swap the model for the entire session — your orchestrator, -your utility calls, everything moves at once. +- **Claude Code → Codex:** registered OpenAI models and aliases are translated to + Responses. Other traffic continues to Anthropic as a raw relay. +- **Codex → Claude:** registered Claude models and aliases are translated to + Messages. OpenAI models continue to OpenAI. Reverse-enabled sessions adapt the + collaboration namespace so cross-provider task messages are readable. -subswitch is the first proxy that splits traffic **per subagent, by model name**: +SubSwitch does not switch models or billing modes after a failure. Unknown models +retain their originating provider's fallback behavior. Native settings and client +binaries are not patched or downgraded. -- Requests whose `model` resolves to a canonical id in the built-in model - registry (by exact match, family alias, or custom alias) are translated and - sent to the Codex backend. -- Everything else — the main agent, background utility calls (token counting, - context management), all non-matching models — is relayed to Anthropic as - **verbatim bytes**, credentials and all. - -So you keep Claude Opus/Sonnet driving the session and delegate a specific -subagent to GPT for a second opinion, a cheaper worker, or a specialized task — -without giving up either subscription and without touching an API key. - -``` -Claude Code ──► subswitch (127.0.0.1:4141) - ├─ model ∈ registry ─────► chatgpt.com Codex backend - │ (Anthropic Messages ⇄ OpenAI Responses translation, - │ ~/.codex/auth.json OAuth, reasoning round-trip cache) - └─ everything else ──────► api.anthropic.com - (verbatim byte relay, claude.ai OAuth untouched) ``` +Claude Code ──► SubSwitch ──► OpenAI for selected OpenAI models + └─► Anthropic for other requests -Routing is by the request body's `model` field, resolved against the built-in -model registry (by exact id, family alias, or custom alias). Unresolvable models -pass through to Anthropic; non-matching utility traffic is never misrouted. +Codex ───────► SubSwitch ──► Anthropic for selected Claude models + └─► OpenAI for other requests +``` ## Requirements -- Node 22+ +- Node 22.15 or newer within Node 22, or Node 24+ (native zstd support); Node 23 is unsupported - A claude.ai subscription login in Claude Code (no `ANTHROPIC_API_KEY` set) - Codex CLI logged in (`codex login` → `~/.codex/auth.json`) @@ -105,14 +91,14 @@ on Claude. ### CLI reference ``` -subswitch — local subscription-routing proxy for Claude Code +subswitch — local subscription-routing proxy for Claude Code and Codex Usage: subswitch [command] [flags] Commands: serve Start the proxy (default command) - doctor Check config, codex auth, and network reachability - init Interactive setup — writes config + wires Claude Code + doctor Check config, subscription auth, and network reachability + init Interactive setup — wires the selected client(s) models Show effective alias table (registry × aliases) --json Output model registry as JSON (no color, no TTY check) @@ -132,6 +118,11 @@ Flags (init): --settings-target "local" (.claude/settings.local.json, default) or "shared" (.claude/settings.json) +Flags (init, doctor, models): + --client "claude-code", "codex", or "all" + init/models default to claude-code; doctor defaults to all + all selects supported clients; both is a compatibility alias + Examples: subswitch serve # start proxy on port 4141 subswitch serve --port 8080 # start proxy on a custom port @@ -191,6 +182,80 @@ npm run serve # same as `subswitch serve` npm run doctor # same as `subswitch doctor` ``` +## Codex → Claude + +Sign in normally with both clients, then enable the reverse path: + +```sh +subswitch init --client codex +subswitch serve +subswitch doctor --client codex +subswitch models --client codex +``` + +`init --client codex --dry-run` previews changes. Use `--yes` for non-interactive +setup, or `--client all` to configure native Codex and the existing project-level +Claude Code integration together. The default `init` behavior remains Claude Code. +`all` selects every client supported by this build (currently Claude Code and Codex); +`both` remains accepted as a compatibility alias. New clients require their own adapters. + +Codex setup changes only the user-level `openai_base_url` value, preserving other +TOML settings and comments. It writes SubSwitch's user config to +`$XDG_CONFIG_HOME/subswitch/config.json` (default `~/.config/subswitch/config.json`). +Project configuration merges over this fallback; explicit `SUBSWITCH_CONFIG` +remains authoritative. Start the proxy before launching Codex. Existing custom +providers are not replaced. A custom upstream is preserved only when it is loopback or +you have explicitly set `codexIngress.allowInsecureBaseUrl: true` in the SubSwitch config. +Setup names an unapproved host and stops before writing files. + +Set the model in a native Codex role file, for example `~/.codex/claude-worker.toml`: + +```toml +model = "sonnet" +model_reasoning_effort = "medium" +``` + +Reference that file from native `~/.codex/config.toml` if the role is not already +configured: + +```toml +[agents.claude_worker] +description = "Delegate a task to Claude" +config_file = "claude-worker.toml" +``` + +Use `sonnet`, `opus`, `fable`, or a listed canonical Claude ID. Custom aliases live +under `codexIngress.claude.aliases`. Canonical IDs retain precedence, and family +aliases select the newest registered generation, as in the forward resolver. +Custom targets outside the registry can route but do not gain invented native +capability metadata; doctor flags them for review. Model registration does not override provider +account availability. Native model discovery preserves OpenAI's catalog and adds +Claude entries. API-authenticated native Codex traffic retains its API endpoint; +translated Claude inference uses the configured subscription, with no billing fallback. + +The reverse path uses the existing Claude Keychain/file credential store and +refreshes it when needed. `codexIngress.claude.configDir` selects a native Claude +configuration directory; if omitted, `CLAUDE_CONFIG_DIR` and then the native default +apply. `codexIngress.claude.authFile` explicitly selects a file-backed store. +Automatic prompt caching is enabled, with cached-token counts and a short hashed +native thread identifier available in logs. If native Codex omits its bearer token on the local +endpoint, SubSwitch uses its existing Codex credential manager only when the +native account ID matches. Incoming credentials are never reused for Claude. + +Claude subscription requests include a native identity system preamble. This +compatibility behavior is explicit; SubSwitch does not forge billing attestations, +rewrite product names in user instructions, or run a second agent runtime. Provider +credential-use policies still apply. See [security](SECURITY.md) and the +[live verification notes](e2e/gates/production-parity.md). + +State is process-local in both directions. Durable restart/resume and translated +compaction are tracked in [#45](https://github.com/dean0x/subswitch/issues/45), setup +undo in [#46](https://github.com/dean0x/subswitch/issues/46), explicit API auth for +both translating paths in [#47](https://github.com/dean0x/subswitch/issues/47), and +broader content/tool support in [#48](https://github.com/dean0x/subswitch/issues/48). +These are not included in the parity release. Missing reverse continuation state +produces an explicit error; keep the proxy running during active translated sessions. + ## Effort control The optional `effort` frontmatter field works on the Codex leg too. Claude Code @@ -210,7 +275,13 @@ default. The config file is located by the following precedence (highest wins): 1. `SUBSWITCH_CONFIG` env var — absolute or `~`-relative path; **missing file is an error** -2. `subswitch.config.json` in the current working directory — silently uses defaults if absent +2. `subswitch.config.json` in the current working directory, merged over the user fallback +3. `$XDG_CONFIG_HOME/subswitch/config.json` (default `~/.config/subswitch/config.json`), then built-in defaults + +An explicit `SUBSWITCH_CONFIG` file is authoritative and is not merged with the user fallback. +This merge applies to all commands, including forward-only use. `doctor` displays every +loaded source, and errors identify source paths. Explicit config paths passed by callers +of `loadConfig` also bypass the merge. **An unrecognised key is rejected, not ignored.** Two checks run against the raw file before it is parsed, and a hit on either is a hard load failure — subswitch prints the @@ -291,12 +362,30 @@ All keys and their defaults: | `providers.codex.allowInsecureBaseUrl` | `false` | **Security opt-in** — when false (the default), `subswitch serve` refuses to start if `providers.codex.baseUrl` or `providers.codex.oauthTokenUrl` points at a host other than `chatgpt.com` or `auth.openai.com`. This prevents credential forwarding to an untrusted host. Set to `true` only when routing through a trusted proxy. Loopback addresses are always exempt. | | `limits.maxBufferedBodyBytes` | `33554432` (32 MiB) | Maximum request body bytes the relay buffers. A larger body is streamed to Anthropic unmodified; on a translated (Codex) route it is answered `413 request_too_large`. | | `limits.pingIntervalMs` | `15000` (15 s) | Interval between SSE ping frames sent to clients during long Codex streams | - -> **Why `connectTimeoutMs` and `maxUpstreamSockets` are Anthropic-leg-only**: the -> Anthropic passthrough uses a node:http agent with an explicit keep-alive pool, so -> both knobs have meaningful effect there. The Codex leg uses Node's global `fetch` -> (undici's global dispatcher), which these knobs do not control — shipping them as -> per-provider keys would be config that bounds nothing on the Codex side. +| `codexIngress.enabled` | `false` | Enable the reserved native Codex ingress endpoints. | +| `codexIngress.subscriptionBaseUrl` | `"https://chatgpt.com/backend-api/codex"` | Native Codex subscription upstream. | +| `codexIngress.apiBaseUrl` | `"https://api.openai.com/v1"` | Native OpenAI API upstream; uses client-supplied credentials. | +| `codexIngress.connectTimeoutMs` | `10000` | TCP connection establishment budget only; no TLS, HTTP-header, or WebSocket-handshake deadline. | +| `codexIngress.maxUpstreamSockets` | `256` | Maximum sockets per raw HTTP upstream pool, plus a shared bound for native upstream WebSocket lifetimes. | +| `codexIngress.allowInsecureBaseUrl` | `false` | **Security opt-in** — allow non-default OpenAI upstream hosts or ports only when trusted. Loopback is exempt. | +| `codexIngress.claude.enabled` | `false` | Enable Claude model resolution, translation, discovery, and collaboration namespace adaptation. | +| `codexIngress.claude.baseUrl` | `"https://api.anthropic.com"` | Claude Messages upstream. | +| `codexIngress.claude.oauthTokenUrl` | `"https://platform.claude.com/v1/oauth/token"` | Claude subscription refresh endpoint. | +| `codexIngress.claude.configDir` | `unset` | Native Claude config directory; otherwise uses `CLAUDE_CONFIG_DIR` or `~/.claude`. | +| `codexIngress.claude.authFile` | `unset` | Explicit credential file; otherwise uses the native macOS Keychain or the config directory credential file. | +| `codexIngress.claude.aliases` | `{}` | Custom aliases targeting `claude-*` IDs; cannot claim OpenAI names. Exact canonical IDs retain precedence. | +| `codexIngress.claude.allowInsecureBaseUrl` | `false` | **Security opt-in** — allow trusted non-default Claude inference/refresh hosts or ports. Loopback is exempt. | +| `codexIngress.claude.requestTimeoutMs` | `600000` | Wall-clock limit for each translated Claude request, including refresh and streaming. | +| `codexIngress.claude.streamIdleTimeoutMs` | `300000` | Maximum upstream SSE idle interval; resets when data arrives. | +| `codexIngress.claude.maxSseEventBytes` | `4194304` | Maximum bytes in one Claude SSE event. | +| `codexIngress.claude.maxAggregateBytes` | `67108864` | Maximum accumulated Claude event bytes per response; excess returns a synthesized 502. | +| `codexIngress.claude.reasoningCache.maxEntries` | `4096` | Shared LRU entry ceiling across continuation snapshots, thinking replay, and adapted-response markers. | +| `codexIngress.claude.reasoningCache.maxBytes` | `67108864` | Shared serialized UTF-8 byte ceiling, including cache keys. Oversized entries are not cached; evicted continuation/replay state returns 409. | + +> **Transport scope**: `anthropic.*` and `codexIngress.*` transport limits control their +> raw HTTP pools. `codexIngress.maxUpstreamSockets` also bounds the total pending/active +> upstream WebSocket connections until they close; waiting clients acquire a slot when +> one closes. Translating provider requests use `fetch` and their own request/idle limits. > **Operator caveats for `connectTimeoutMs`**: (1) **No effect on pooled sockets.** > With `maxUpstreamSockets: 256` and keep-alive on, steady-state traffic reuses @@ -401,7 +490,8 @@ subswitch models --json | jq .models[].id **Field notes**: - `schemaVersion` is an integer that bumps on any breaking change to this structure. - Consumers must check `schemaVersion === 1` before reading other fields. + Consumers of the default/`--client claude-code` shape must check `schemaVersion === 1` before reading other fields. + The Codex and combined shapes use version 2 and the `client` discriminator described below. - `gen` is an integer tuple (`[5, 6]`), not a string (`"5.6"`). String comparison sorts `"5.10"` before `"5.9"` — the tuple is the correct form for numeric comparison. `gen` is omitted when the generation is unknown; it is always present for registry entries. @@ -414,6 +504,17 @@ subswitch models --json | jq .models[].id - `aliases[].source` is `"derived"` for family aliases computed from the registry, or `"config"` for entries you wrote in `providers.codex.aliases`. +For `--client codex`, version 2 has this shape (model rows are abbreviated): + +```json +{"kind":"models","schemaVersion":2,"client":"codex","subswitchVersion":"0.4.0","fallbackProvider":"codex","enabled":true,"models":[{"id":"claude-sonnet-5","provider":"claude","registered":true,"aliases":["sonnet"]}]} +``` + +For `--client all`, version 2 uses `client: "all"` and a `clients` object: +`{"kind":"models","schemaVersion":2,"client":"all","clients":{"claude-code":,"codex":}}`. +Branch on both `schemaVersion` and `client`. Codex rows contain `id`, `provider`, +`registered`, and `aliases`; they do not use the version-1 row schema. + ## How the Codex leg works - **Auth**: reads `~/.codex/auth.json`, proactively refreshes the OAuth access @@ -433,6 +534,34 @@ subswitch models --json | jq .models[].id sequence, with pings during upstream silence; non-streaming clients get an aggregated JSON message. +## How the Claude reverse leg works + +- **Auth:** reads native Claude subscription storage and refreshes a rejected token once. + Requests include the Claude subscription identity preamble and beta headers. Native + OpenAI credentials never reach Claude; translated failures never switch billing modes. +- **Routing:** HTTP and WebSocket requests use the same resolved Claude destination and + route decision. Unresolved models retain OpenAI handling. Operator Codex credential + substitution is limited to exact `/responses`, `/responses/compact`, and `/models` paths + in subscription mode and requires a matching native account. +- **Translation:** Responses input becomes Messages history. Text streams incrementally; + executable tool calls commit only after a valid terminal message. Response protocol + failures return 502; missing process-local continuation or thinking state returns 409 + with guidance to start a new conversation. Missing relay-side Claude credentials return + 503 with Claude-specific sign-in guidance, avoiding an unrelated native OpenAI login refresh. +- **State:** snapshots, authenticated thinking replay, and collaboration adaptation markers + share one bounded process-local LRU. Interrupted streams retain an empty replay handle + so native cancellation notices and readable partial text can continue in the same + conversation. Unfinished thinking and tool calls are not replayed. Restart or eviction + makes that state unavailable. +- **Collaboration:** when Claude routing is enabled, native collaboration definitions and + structured calls are adapted for the whole Codex session, including OpenAI turns. The + affected message arguments use the explicit plaintext tool contract. Existing opaque + histories and prompt text remain unchanged; encrypted arguments cannot become executable + plaintext calls. Disabling Claude routing disables this adaptation. +- **Raw relay:** connection-specific headers, including headers named by `Connection`, are + stripped on both legs in both directions. Other provider headers and payload bytes survive. + The `/codex` namespace stays reserved when disabled and returns OpenAI-shaped errors. + ## Logging Structured single-line logs with a closed field set (model, path, route, @@ -460,10 +589,14 @@ how the request was dispatched. Valid values: | `anthropic:fallback` | Fail-open forward: colon-qualified name whose prefix is not a registered provider; relay forwards to Anthropic and emits `unknown_provider_qualifier` warn | | `codex:{endpoint}:{model}` | Request routed to the Codex provider leg (e.g. `codex:messages:gpt-5.6-sol`, `codex:count_tokens:gpt-5.6-sol`) | | `host_rejected` | Request refused by the loopback `Host`/`Origin` gate before routing; relay returned a synthesized 403 | +| `codex_ingress:subscription:passthrough` | Subscription-mode Codex ingress; the gateway may translate a resolved Claude request. | +| `codex_ingress:api:passthrough` | API-mode Codex ingress; the gateway may translate a resolved Claude request. | +| `codex_ingress:unknown` | Reserved `/codex` path not belonging to either supported endpoint base. | | `internal_error` | Unhandled exception during request handling; relay returned a synthesized 500 | The `anthropic:ambiguous` and `anthropic:fallback` values both carry the `anthropic` prefix so -leg-level filtering (`route starts with anthropic`) continues to work. The suffix makes +leg-level filtering (`route starts with anthropic`) continues to work. Codex ingress +uses the separate `codex_ingress` prefix; its ingress route label is not a destination-provider label. The suffix makes fail-open forwards distinguishable from intended Anthropic routes in log queries and alerting. ### Log events diff --git a/SECURITY.md b/SECURITY.md index 5d85717..8a8ac96 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,3 +53,66 @@ Defense-in-depth controls built into the proxy: Static-analysis findings that are intentional given the loopback threat model (e.g. plain-HTTP local binding, relaying bounded upstream error detail to the local client) are documented with rationale in [`.snyk`](.snyk). + +## Bidirectional development probes + +The opt-in source-checkout command `npm run probe:compat` reads only the selected +provider's credential store or named API-key environment variable. It does not +rewrite credentials, refresh tokens, change native client configuration, execute +tools, or switch billing modes. Requests use fixed fabricated messages and official +provider endpoints; redirects are rejected. Reports omit raw errors, content, +credentials, thinking, and signatures. See the [gate results and limits](e2e/gates/README.md). + +The separate native-client probes create temporary configuration directories and +OS-assigned loopback listeners. `probe:native-codex` uses fabricated credentials +and local upstreams, with a fixed native `printf` tool check. `probe:native-claude` +uses the published Claude CLI, passes an existing subscription token in its token +environment variable, and enables only Agent/Read for a temporary file check. +Neither changes shared client configuration or writes a shared credential store. +Each runner owns its process group and removes its temporary files. These controls +do not certify direct third-party subscription inference. + +The native Claude runner's explicit `--openai` variant checks the existing forward +translator using a temporary Codex access-token file outside its working directory. +It omits the real refresh token and sends refresh attempts only to a local rejection +endpoint, so shared Codex login state cannot be rotated. The temporary file is mode +0600 inside the run's private directory and is removed at cleanup. + +## Codex ingress and Claude subscription routing + +`codexIngress.enabled` enables the native Codex ingress. The separate +`codexIngress.claude.enabled` switch enables Claude model routing; both default +to false. Host/Origin checks also cover upgrades. Foreign upstreams require an +explicit opt-in, and remote destinations require TLS. + +Native OpenAI credentials are preserved when present. Native Codex can omit the +bearer token on a local endpoint; subscription ingress then uses the existing Codex +credential manager only when the native account ID matches its configured store. +API ingress never substitutes a subscription credential. The manager is shared +with the forward translator to keep refresh single-flight across both directions. + +Claude requests use only the selected Claude store: native Keychain on macOS or +the native file-backed store elsewhere, with explicit config-directory/file +overrides available. Incoming OpenAI credentials never reach Anthropic. Refresh +preserves unrelated credential fields, checks external rotation, and verifies +Keychain writes. Password data is sent to the Keychain command through stdin, +not process arguments. No credential is written into project configuration. + +Reverse-enabled collaboration schemas/history are mapped through a SubSwitch +namespace. Only known-plaintext calls receive native plaintext markers. Claude +subscription requests include the native identity system preamble; this is explicit +compatibility behavior, not a client binary patch or an upstream support commitment. +Provider credential-use policies and billing classification still apply. + +Claude thinking/signatures are retained in bounded process-local state referenced +by authenticated opaque handles. Replay verifies associated message/tool content. +Missing or altered state fails explicitly; durable state and translated compaction +are outside this parity change. Executable tool calls are committed only after a +valid terminal provider event. Native Codex executes the tools under its own +permissions; SubSwitch does not run an agent or tool runtime. + +The production acceptance runner uses temporary access-only credential files, +real model discovery, and isolated native settings. It tests actual upstreams +without rotating shared stores. Its HTTP control rejects WebSocket upgrades and +otherwise relays bytes, exercising native fallback without changing client flags. +See [production acceptance](e2e/gates/production-parity.md). diff --git a/e2e/README.md b/e2e/README.md index c41cae4..c26b147 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,5 +1,12 @@ # subswitch end-to-end verification +The [production Codex → Claude path](gates/production-parity.md) passes live native +sub-agent/tool round trips and follow-ups. Enable it with `init --client codex` or +`init --client both`. The [scope matrix](gates/parity-scope.md) records shared +follow-ups outside the current parity implementation. + +The instructions below cover the existing Claude Code → OpenAI direction. + Manual verification against the real Claude Code CLI and real upstreams. Run each step in order; every step depends on the previous one working. diff --git a/e2e/codex-passthrough.md b/e2e/codex-passthrough.md new file mode 100644 index 0000000..767490b --- /dev/null +++ b/e2e/codex-passthrough.md @@ -0,0 +1,82 @@ +# Native Codex passthrough foundation + +For the completed opt-in reverse feature, see [production parity](gates/production-parity.md). +The original transport-only notes below describe the raw mode with Claude routing disabled. +Native subscription requests that omit their bearer header now use a matching configured +Codex credential store; API mode does not substitute subscription credentials. + +Codex-facing transport is enabled by `codexIngress.enabled`, which defaults to +**false**. This example keeps `codexIngress.claude.enabled` false and therefore +selects raw transport. `init --client codex` enables the separate Claude routing path. + +For a local transport test, add the following to a SubSwitch configuration: + +```json +{ + "codexIngress": { + "enabled": true, + "subscriptionBaseUrl": "https://chatgpt.com/backend-api/codex", + "apiBaseUrl": "https://api.openai.com/v1" + } +} +``` + +Then start `subswitch serve`. The ready banner and health response identify this +as OpenAI passthrough with Claude translation unavailable. Native client settings +are left unchanged; start the proxy explicitly before pointing a test client at it. + +| Local base path | Configured upstream | Credential source | +| --- | --- | --- | +| `/codex/backend-api/codex` | `subscriptionBaseUrl` | Original native request | +| `/codex/v1` | `apiBaseUrl` | Original native request | + +The path chooses the endpoint and authentication mode. Supplied credentials are +preserved. Subscription requests that omit their bearer token can use the matching +configured Codex store. The proxy never switches endpoint or billing mode after a failure. All models, including unknown and Claude-looking names, pass +through to the selected OpenAI endpoint unchanged. No Claude model is advertised. + +This foundation provides: + +- HTTP streaming and non-streaming byte relay, preserving paths, queries, body + whitespace, compressed bytes, upstream statuses, error bodies and `Retry-After`. +- A raw WebSocket tunnel preserving handshake headers and frame bytes, including + bytes received during the upgrade handshake and multiple messages on one connection. +- Unmodified model discovery and compaction passthrough. +- Namespace isolation: unknown `/codex/*` paths return a local Responses-shaped + 404; disabled known paths return 503. Neither reaches Anthropic. +- Loopback Host/Origin protection for HTTP and WebSocket ingress, trusted-upstream + validation, cancellation and shutdown cleanup, backpressure, and connect-only + timeouts. Established streams have no new proxy deadline. + +Standard hop-by-hop headers, including fields named by `Connection`, are stripped. +The synthesized-response marker is reserved for local errors. Native request +headers and duplicate end-to-end response headers otherwise retain their values. +HTTP errors on a rejected WebSocket upgrade retain upstream status and body. +Redirects are relayed to the client; the proxy does not follow them with credentials. + +`allowInsecureBaseUrl` must be explicitly true for non-default remote hosts or +ports. HTTPS is required for remote destinations even with that option. Loopback +HTTP is allowed for local fake upstreams. URLs containing embedded credentials, +queries, or fragments are rejected. Configuration does not contain API keys. + +The new ingress does not inspect or decompress request bodies. Even malformed +JSON and unknown content encodings remain the upstream's responsibility. Separate +encoded/decoded translation bounds, model-based WebSocket dispatch, continuation +state, plaintext compatibility, and Claude adapters belong to the gated reverse +implementation. They are not supplied by this transport slice. + +The integration tests use fake upstreams, including raw upgraded sockets. They +prove transport preservation and lifecycle behavior, not authenticated native +multi-agent interoperability or reverse-direction release acceptance. + +The additional [isolated native runners](gates/README.md) now exercise real Codex +v1/v2 spawning, code tools, parent delivery, v2 follow-up, WebSocket warmup and +continuation, and native HTTP fallback through this proxy. Their model upstreams +are fabricated. A separate live native Claude Sonnet parent/child/Read control also +passes through the existing Anthropic leg. Neither is a live Codex → Claude test. + +```sh +node --import tsx --test test/integration/codex-ingress.test.ts +npm run check +bash scripts/smoke-tarball.sh +``` diff --git a/e2e/gates/README.md b/e2e/gates/README.md new file mode 100644 index 0000000..fa6eda8 --- /dev/null +++ b/e2e/gates/README.md @@ -0,0 +1,199 @@ +# Bidirectional compatibility verification + +**Current implementation:** see [production parity acceptance](production-parity.md) +and the [agreed scope](parity-scope.md). The notes below retain the earlier native +contract investigations; the production gateway now passes the core live flow and +is available through `init --client codex` / `init --client both`. + +## Historical gate and prototype results + +The historical prototype passed the native v2 tool round trip and follow-ups +before production routing was wired. See the [live reverse results](live-reverse.md) +for WebSockets, HTTP fallback, native subscription authentication, and limitations. +The existing Claude Code → OpenAI path and opt-in +[raw Codex passthrough](../codex-passthrough.md) remain available. + +The later [Pi/Hermes source comparison and implementation inventory](provider-research.md) +narrows the direct Claude 429 to a tested dependency on the native system identity +preamble. A reproducible reduction test is available with +`node --import tsx e2e/gates/claude-system-control.ts`. Its exit 0 means the rejection +was reproduced, not that reverse integration passed. + +## Isolated native checks + +From a source checkout with dependencies and Codex CLI 0.153.3 installed: + +```sh +npm run probe:native-codex +npm run probe:native-codex -- --v1 +npm run probe:native-codex -- --http +npm run probe:native-codex -- --namespace-adapter +``` + +Each command starts the actual SubSwitch proxy and a fabricated upstream on separate +OS-assigned loopback ports. It creates a temporary `CODEX_HOME`, working directory, +Sonnet role, model catalog, and fabricated API credential. No real Codex credential +is read and no model provider is contacted. Fixture metadata selects v1 or v2 +natively; no real session or feature setting is downgraded. + +Codex spawns a `claude-sonnet-5` child, executes a fixed `printf` through its native +code tool, returns the tool result, and delivers the child answer to its parent. +V2 also exercises `followup_task` and the second child reply. This tests client +orchestration and raw transport, **not Claude inference or reverse translation**. +Normal runs retain WebSockets, warmup, connection reuse, and `previous_response_id`. +`--http` makes the fake upstream reject the upgrade to test native HTTP fallback; +it does not disable WebSockets in client configuration. Native HTTP requests in this +run were not compressed. + +The namespace-adapter variant tests the ordinary upstream namespace and restored +native call names/markers, after an unchanged native control. Both pass on the +installed Codex 0.153.4. The runner now derives the model-cache version from the +installed client, avoiding stale test catalogs after native updates. This remains +an experimental source-only adapter, not a reverse provider implementation. + +For the separate live native Claude control: + +```sh +npm run probe:native-claude +npm run probe:native-claude -- --openai +``` + +This passes the user's existing subscription token to the unmodified native CLI +through its supported token environment variable. A temporary Claude configuration, +working directory, and separate SubSwitch proxy isolate the run. A Sonnet parent +delegates to a Sonnet role whose only tool is `Read`. The child reads an unpredictable +value from a temporary file and returns it through a real tool-result continuation. +Only `Agent` and `Read` are enabled. Hooks, inherited settings, slash commands, and +MCP configuration are excluded; managed policies still apply. Credentials are not +written into project files or shared stores, and no new login is performed. + +`--openai` instead assigns the native Claude child to `gpt-5.5`, exercising the +existing Claude Code → OpenAI translator. It reads the existing Codex access token +into a restricted temporary auth file outside the working directory, omits the +real refresh token, and disables refresh against a local endpoint. Shared Codex +credentials cannot be rotated by this test. Both providers use explicitly selected +subscription authentication. The child must perform Read and complete a tool-result +continuation through the translating handler. + +The runners close stdin, bound runtime/output, and own their process groups. +Timeouts remain failures even if a wrapper exits zero after SIGTERM. Cleanup targets +only that run's processes and files. Reports contain allowlisted counts, statuses, +and booleans; they never emit native stdout/stderr, tool output, prompts, credentials, +signatures, or raw metadata. + +## Direct backend checks + +```sh +npm run probe:compat -- --provider openai --model gpt-6-astra +npm run probe:compat -- --provider openai --model gpt-6-astra --contract native-history +npm run probe:compat -- --provider openai --model gpt-6-astra --contract native-arguments +npm run probe:compat -- --provider openai --model gpt-6-astra --contract schema-fields +npm run probe:compat -- --provider openai --model gpt-6-astra --contract namespace-control +npm run probe:compat -- --provider claude --model claude-sonnet-5 +``` + +The default OpenAI sequence tests the unchanged schema, the proposed three +`message.encrypted=false` changes, readable generated arguments, and plaintext +history. Failed prerequisites stop that sequence. `native-history` independently +tests known-plaintext replay with unchanged schemas. `native-arguments` asks the +unchanged schema to spawn a Sonnet child with a fixed readable task. Both independent +checks include the native positive control. Direct probes execute no agents or tools; +all messages and histories are fabricated. + +`schema-fields` compares each annotation independently, false and omitted, after +one positive control (at most seven requests). `namespace-control` tests an ordinary +SubSwitch namespace, readable generated arguments, and replay (at most four requests). +These diagnostics do not enable production rewrites. Auth, network, and availability +failures stop each sequence without fallback. + +Subscription is the default. OpenAI reads `$CODEX_HOME/auth.json` (default +`~/.codex/auth.json`). Claude reads its native macOS Keychain service, or +`$CLAUDE_CONFIG_DIR/.credentials.json` on other platforms (default +`~/.claude/.credentials.json`). An explicit Claude config directory selects a +separate Keychain service. Only the default macOS store was exercised live. Store +errors and expired tokens stop the check without credential refresh or fallback. + +API mode is a separately selected test, never a fallback: + +```sh +npm run probe:compat -- --provider openai --auth api --model gpt-6-astra --key-env OPENAI_API_KEY +npm run probe:compat -- --provider claude --auth api --model claude-sonnet-5 --key-env ANTHROPIC_API_KEY +``` + +An available environment variable never selects API mode. Requests use official +endpoints, reject redirects, and have development bounds of 30 seconds and 1 MiB. +There are no retries, alternate models, billing fallback, or native configuration +changes. The final stdout line is a versioned, redacted JSON report. Exit 0 means +selected checks passed; 1 means a contract or argument failed; 2 means credentials, +transport, or an upstream rejection blocked the check. HTTP 429 alone does not +diagnose subscription exhaustion. + +## Observed results — 2026-09-06, Asia/Jerusalem + +Environment: Node 22.22.3, Codex CLI 0.153.3, Claude Code 2.1.261, macOS. + +| Check | Result | +| --- | --- | +| Native Codex v2, fabricated upstream | Pass: child model routing, native tool execution/result, parent delivery, follow-up; 2 WebSocket connections and 8 continuations | +| Native Codex v1, fabricated upstream | Pass: configured Sonnet role, native tool execution/result, parent delivery over WebSockets | +| Native Codex v2 HTTP fallback | Pass: child/tool/follow-up flow; 8 HTTP requests | +| Native Claude Sonnet parent → Sonnet child → Read → parent | Pass through SubSwitch: 5 Messages requests, tool-result continuation and final value | +| Existing Claude Code → OpenAI child → Read → parent | Pass live: 2 translated requests; also passed concurrently with the isolated Codex v2 runner | +| OpenAI subscription, unchanged native v2 schema | HTTP 200, completed response | +| Same request with three message encryption annotations set false | HTTP 400, reserved `collaboration.followup_task` schema rejected | +| Each annotation separately false or omitted | All six requests return HTTP 400, naming the changed reserved function | +| Ordinary `subswitch_collaboration` namespace | HTTP 200 for schema, exact readable Sonnet task generation, and replayed history | +| Experimental namespace adapter, native Codex 0.153.4, fake upstream | Native v2 child, tool, parent, and follow-up pass over WebSockets and HTTP fallback, each after an unchanged control | +| Corrected native plaintext history, unchanged schema | HTTP 200, completed response | +| Unchanged schema explicitly spawning a Sonnet child | HTTP 200, requested child model selected; expected readable task absent, opaque 140-character task returned | +| Direct Claude subscription forced tool request | HTTP 429, generic `rate_limit_error`, no `Retry-After`; continuation not reached | +| Direct Claude controls | Same rejection with HTTPS and fetch, streaming and non-streaming, beta query endpoint, plain text without tools, and adaptive thinking with automatic tool choice | +| Explicit OpenAI API authentication | HTTP 401 at native-schema control; later stages not reached | +| Claude API authentication | Not run: no API credential available | + +Native Claude succeeds, so the earlier capacity diagnosis is withdrawn. The direct +429 identifies neither exhaustion nor credential-use enforcement. No inspected +rate-limit reset guidance or explicit `enforced_spend_limit_reached` detail was +returned. The probe distinguishes an explicit spend-limit detail from a generic +rejection. [Anthropic rate-limit reference](https://platform.claude.com/docs/en/api/rate-limits) + +## Corrected wire contract + +The earlier probe incorrectly inferred protocol markers from binary strings. +`[plaintext arguments]` and `[plaintext]` are **log-redaction labels**, not wire +values. Native Codex identifies readable collaboration arguments with +`encrypted_function_args: []`. Plaintext `agent_message` content is an array of +`{type: "input_text", text: ...}` blocks, and its ID begins with `amsg`. + +The installed client and real OpenAI history endpoint now validate these shapes. +See the public source for [tool-call classification](https://github.com/openai/codex/blob/f5a71ff40a713eff0bb3feeb22e2ca0e1c208a08/codex-rs/core/src/tools/router.rs) +and [agent-message representation](https://github.com/openai/codex/blob/f5a71ff40a713eff0bb3feeb22e2ca0e1c208a08/codex-rs/protocol/src/models.rs). +No generated ciphertext is relabeled or decrypted. + +The probe also consumes completed output items when a Responses-lite terminal +event leaves `output` empty. It still requires a valid terminal event and rejects +missing or duplicated item indices. A completed tool item or EOF alone is insufficient. + +The [collaboration fixture](../../test/fixtures/native/codex-0.153.3-collaboration.json) +retains the full native namespace. The [model fixture](../../test/fixtures/native/codex-0.153.3-model.json) +contains public metadata and fabricated instructions. Labelling a fake Sonnet model +with this fixture is not a claim about Claude's real capabilities or context limits. + +## Current parity boundary + +The production path is covered in [production acceptance](production-parity.md). +The reserved-schema diagnostic remains a negative control; production uses the +reversible namespace adapter. Native identity compatibility is documented and +included in the user-approved parity scope. + +Durable restart/resume, translated compaction, setup undo, explicit API modes for +translated inference, and broader content/tool extensions are shared follow-ups +[#45–#48](https://github.com/dean0x/subswitch/issues/45). These are not release claims. +The upstream report drafts remain unsent. Source-only runners and fixtures are +excluded from the installed package. + +### Native runner entrypoints + +`npm run probe:native-reverse` runs the experimental reverse harness in `native-reverse.ts`. +`npm run probe:native-production` runs `native-production.ts`, the production gateway +acceptance runner. These are live, credential-using gates and are separate from `npm test`. diff --git a/e2e/gates/blockers.md b/e2e/gates/blockers.md new file mode 100644 index 0000000..076d861 --- /dev/null +++ b/e2e/gates/blockers.md @@ -0,0 +1,95 @@ +# Exact constraints and the alternative now demonstrated + +The core production flow is now implemented and verified. See +[production parity acceptance](production-parity.md); the remaining shared gaps are +tracked in issues #45–#48. This file retains the earlier research chronology. + +**Update, 2026-09-07:** the [live reverse prototype](live-reverse.md) now passes +OpenAI parent → native Claude-backed Codex child → native tool/result → parent, +including same-child follow-up, both transports, and native subscription auth. +The discovered preamble behavior is sufficient for these real Codex tasks. Its +production scope is pending; broad compatibility and release work remain. + +## OpenAI: reserved schema rejected, reversible namespace translation works + +The native `collaboration` schema requests encrypted task messages. OpenAI selects +the requested Sonnet child, but returns an opaque task. The model name remains +readable, so routing works; the task needed for Claude does not. SubSwitch has no +corresponding decryption key. Ports, aliases, WebSocket handling, and a local bridge +encryption key cannot recover plaintext from that payload. + +The independent per-field test now establishes the precise rejection: setting +`encrypted` false **or removing it** returns HTTP 400 for each of `spawn_agent`, +`send_message`, and `followup_task`. Each error names the changed reserved function +and requires its configured schema. The unchanged control returns HTTP 200. + +However, the same definitions under `subswitch_collaboration`, with the three +annotations false, are accepted and generate the exact readable Sonnet task. +The restriction is specific to the reserved namespace, not plaintext tool calling. + +An experimental [namespace adapter](namespace-adapter.ts) now maps structured +definitions, tool choices, and replayed calls upstream, and restores native call +names and plaintext metadata downstream. It leaves prompt text and opaque reasoning +untouched, rejects namespace collisions, and rejects malformed or explicitly +encrypted calls. It does not patch Codex or select v1. The installed native Codex +v2 client passes its child/tool/parent/follow-up flow with this adapter and a fake +upstream over WebSockets and HTTP fallback. Live OpenAI schema, generation, and +history replay controls pass separately. The later live prototype integrates the +namespace adapter with a real Claude child; it is still not a production feature. + +The tradeoff is explicit: collaboration traffic from an activated parent is adapted, +including OpenAI-to-OpenAI messages. An implementation must preserve this mapping +across every transport and replay. It cannot claim all parent traffic is unchanged. + +The native runner also uncovered a fixture problem after Codex updated from 0.153.3 +to 0.153.4: its test model cache still named the old client version. The unchanged +control failed too. The runner now stamps its fabricated catalog with the installed +version and runs that control before testing the namespace adapter. The client itself +is not changed or downgraded. + +## Claude: native system identity affects direct subscription acceptance + +The same subscription and Sonnet model work through unmodified native Claude, +including a subagent and real Read/tool-result continuation through SubSwitch. +Bridge-generated minimal Messages requests return HTTP 429, `rate_limit_error`, generic message `Error`, +without retry guidance or an identified spend-limit detail. + +The failure persists across HTTPS/fetch, streaming/non-streaming, the beta query +endpoint, plain text without tools, and adaptive thinking with automatic tool choice. +It is therefore insufficient to blame forced tool choice or an unusable login. +Later [Pi/Hermes source research and controlled native-request reductions](provider-research.md) +isolate a concrete difference: with SubSwitch's own user-agent and the native beta +selection, a native-generated request succeeds after removing billing and account +metadata. Removing its native identity system preamble reproduces the generic 429. +A generic assistant preamble fails too. The reference clients explicitly supply +native identity statements; several also rewrite prompt content and tool names. + +This narrows the failure to an observed request-content dependency. The internal +classification rule and charged quota remain unknown. Direct tool continuation +and specific real Codex tasks now pass; arbitrary workloads remain unverified. The identity-dependent examples +do not satisfy the original no-impersonation requirement. Neither blanket exhaustion +nor universal technical impossibility follows from these results. + +There is a separate documented support boundary: Anthropic directs third-party +integrations to API authentication and restricts subscription credential +intermediation. That documentation does not prove the cause of this particular +error. [Credential-use documentation](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) + +## What would change the remaining path + +| Angle | Effect and remaining work | +| --- | --- | +| Reversible namespace adapter | Demonstrates a route around the reserved-schema restriction while retaining native v2; integration, durable replay, and live mixed-provider validation remain | +| Explicit Anthropic API authentication | Uses the documented third-party authentication path; needs a valid API credential and live continuation test, incurs separate billing, and does not satisfy subscription-only usage | +| Approved direct subscription integration | Could retain the original authentication requirement; needs the exact supported request contract and successful continuation | +| Native Claude CLI/SDK child runtime | Native subscription inference works, but this introduces a second runtime and changes the requested pure protocol-bridge architecture | + +The narrow impossibility is recovering the task from the existing opaque OpenAI +payload using only the bridge's available inputs. The wider integration is not +proved impossible: namespace translation is a demonstrated alternative. Claude +direct subscription use remains unresolved, not proved universally impossible. + +No authentication fallback or namespace rewrite has been enabled in production. +The reverse provider adapters, durable state, setup/undo, and remaining release +matrix are still implementation work. A valid Anthropic API credential is currently +unavailable; an explicitly selected OpenAI API control previously returned HTTP 401. diff --git a/e2e/gates/claude-system-control.ts b/e2e/gates/claude-system-control.ts new file mode 100644 index 0000000..0902d1e --- /dev/null +++ b/e2e/gates/claude-system-control.ts @@ -0,0 +1,186 @@ +/** Source-only diagnostic. Never synthesizes a Claude identity or billing marker. */ +import http from "node:http"; +import https from "node:https"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { probeHeaders } from "./credentials.js"; +import { nativeProcess, isolatedNativeEnv } from "./native-process.js"; +import { object } from "./probe.js"; +import { createSseParser } from "../../src/codex-response.js"; +import { filterRawHeaders, setRawRequestHeaders, HOP_BY_HOP, RESPONSE_STRIP } from "../../src/raw-http-passthrough.js"; + +const PROMPT = "Reply with exactly gate-ok."; +const MAX_BYTES = 2 * 1024 * 1024; +type Item = Record; +export interface NativeClaudeCapture { path: string; rawHeaders: string[]; body: Item } +interface Observation { + variant: string; httpStatus: number; completed: boolean; gateReply: boolean; + genericRateLimit: boolean; retryAfterPresent: boolean; textCharacters: number; endedTurn: boolean; +} + +async function boundedBytes(message: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; let bytes = 0; + for await (const chunk of message) { + bytes += chunk.length; + if (bytes > MAX_BYTES) { message.destroy(); throw new Error("body_limit"); } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +/** Run a bounded experiment using a request generated by an isolated native client. */ +export async function withNativeClaudeCapture(experiment: (capture: NativeClaudeCapture, headers: Record) => Promise): Promise { + const headers = await probeHeaders({ provider: "claude", auth: "subscription" }); + const pending = new Set(); + let capture: NativeClaudeCapture | undefined; + let requests = 0; + const send = (path: string, rawHeaders: string[], body: Buffer, reply: (response: http.IncomingMessage) => void) => { + const outgoing = https.request({ hostname: "api.anthropic.com", path, method: "POST", + signal: AbortSignal.timeout(30000) }, reply); + pending.add(outgoing); + outgoing.on("close", () => pending.delete(outgoing)); + setRawRequestHeaders(outgoing, filterRawHeaders(rawHeaders, HOP_BY_HOP)); + outgoing.end(body); + return outgoing; + }; + const relay = http.createServer(async (req, res) => { + try { + if (req.method !== "POST" || !/^\/v1\/messages(?:\?|$)/.test(req.url ?? "") || ++requests > 4) { + res.writeHead(404); res.end(); return; + } + const raw = await boundedBytes(req); + const body = object(JSON.parse(raw.toString("utf8"))); + const outgoing = send(req.url!, req.rawHeaders, raw, response => { + if (!capture && response.statusCode === 200 && body?.["model"] === "claude-sonnet-5" && + body["stream"] === true && typeof body["max_tokens"] === "number" && body["max_tokens"] >= 128 && + JSON.stringify(body["messages"])?.includes(PROMPT)) { + capture = { path: req.url!, rawHeaders: [...req.rawHeaders], body }; + } + res.writeHead(response.statusCode ?? 502, filterRawHeaders(response.rawHeaders, RESPONSE_STRIP)); + response.pipe(res); + }); + outgoing.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); }); + } catch { if (!res.headersSent) res.writeHead(400); res.end(); } + }); + let temp: string | undefined; + try { + temp = await mkdtemp(join(tmpdir(), "subswitch-system-control-")); + const work = join(temp, "work"), config = join(temp, "config"); + await mkdir(work, { mode: 0o700 }); await mkdir(config, { mode: 0o700 }); + await new Promise((resolve, reject) => { + relay.once("error", reject); relay.listen(0, "127.0.0.1", resolve); + }); + const address = relay.address(); + if (!address || typeof address === "string") throw new Error("listen_failed"); + const native = await nativeProcess("claude", [ + "--print", "--setting-sources", "", "--settings", '{"disableAllHooks":true}', + "--disable-slash-commands", "--strict-mcp-config", "--no-session-persistence", + "--tools", "", "--permission-mode", "dontAsk", "--model", "claude-sonnet-5", + "--output-format", "json", PROMPT, + ], { cwd: work, env: isolatedNativeEnv({ CLAUDE_CONFIG_DIR: config, + CLAUDE_CODE_OAUTH_TOKEN: headers["authorization"]!.slice("Bearer ".length), + ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}` }), timeoutMs: 45000 }); + const answer = object(JSON.parse(native.stdout)); + if (native.code !== 0 || native.failure || answer?.["is_error"] !== false || + typeof answer["result"] !== "string" || !answer["result"].includes("gate-ok") || !capture) { + throw new Error("native_control_failed"); + } + + return await experiment(capture, headers); + } finally { + capture = undefined; + for (const outgoing of pending) outgoing.destroy(); + relay.closeAllConnections(); + await new Promise(resolve => relay.close(() => resolve())); + if (temp) await rm(temp, { recursive: true, force: true }); + } +} + +/** Reproduce the identity-sensitive rejection, not the reverse provider acceptance gate. */ +export async function runClaudeSystemControl() { + return withNativeClaudeCapture(async (capture, headers) => { + const send = (path: string, rawHeaders: string[], body: Buffer, reply: (response: http.IncomingMessage) => void) => { + const outgoing = https.request({ hostname: "api.anthropic.com", path, method: "POST", signal: AbortSignal.timeout(30000) }, reply); + setRawRequestHeaders(outgoing, filterRawHeaders(rawHeaders, HOP_BY_HOP)); + outgoing.end(body); + return outgoing; + }; + // Keep only our ordinary probe headers and the native feature-beta selection. + // In particular, retain our SubSwitch user-agent, with no x-app or SDK fingerprint. + let beta: string | undefined; + for (let i = 0; i < capture.rawHeaders.length; i += 2) { + if (capture.rawHeaders[i]!.toLowerCase() === "anthropic-beta") beta = capture.rawHeaders[i + 1]; + } + if (!beta || !Array.isArray(capture.body["system"])) throw new Error("native_shape_changed"); + if (Array.isArray(capture.body["tools"]) && capture.body["tools"].length) throw new Error("unexpected_tools"); + const system = capture.body["system"] as unknown[]; + const billing = (value: unknown) => typeof object(value)?.["text"] === "string" && + (object(value)!["text"] as string).startsWith("x-anthropic-billing-header:"); + const identity = (value: unknown) => typeof object(value)?.["text"] === "string" && + /^(You are Claude Code,|You are a Claude agent,)/.test(object(value)!["text"] as string); + if (system.filter(billing).length !== 1 || system.filter(identity).length !== 1) { + throw new Error("native_system_shape_changed"); + } + const retained: Item = { ...capture.body, system: system.filter(value => !billing(value)) }; + delete retained["metadata"]; + const removed: Item = { ...retained, system: (retained["system"] as unknown[]).filter(value => !identity(value)) }; + const rawHeaders = Object.entries({ ...headers, "anthropic-beta": beta }).flat(); + const path = capture.path; + const probe = (variant: string, body: Item): Promise => new Promise((resolve, reject) => { + const outgoing = send(path, rawHeaders, Buffer.from(JSON.stringify(body)), response => { + void (async () => { + // Our headers do not request compression. Fail explicitly if the server changes that contract. + if (response.headers["content-encoding"]) { response.destroy(); throw new Error("unexpected_encoding"); } + const bytes = await boundedBytes(response); + let parsed: Item | undefined; + let text = "", stopped = false, failed = false, endedTurn = false; + if (/text\/event-stream/i.test(String(response.headers["content-type"]))) { + const parser = createSseParser(MAX_BYTES); parser.end(bytes); + for await (const frame of parser) { + if (!frame.data || frame.data === "[DONE]") continue; + const event = object(JSON.parse(frame.data)); + const delta = object(event?.["delta"]); + const block = object(event?.["content_block"]); + if (event?.["type"] === "content_block_start" && block?.["type"] === "text" && + typeof block["text"] === "string") text += block["text"]; + if (event?.["type"] === "content_block_delta" && delta?.["type"] === "text_delta" && + typeof delta["text"] === "string") text += delta["text"]; + if (event?.["type"] === "message_stop") stopped = true; + if (event?.["type"] === "error") failed = true; + if (event?.["type"] === "message_delta" && delta?.["stop_reason"] === "end_turn") endedTurn = true; + } + } else parsed = object(JSON.parse(bytes.toString("utf8"))); + const error = object(parsed?.["error"]); + resolve({ variant, httpStatus: response.statusCode ?? 0, completed: stopped && !failed, + textCharacters: text.length, endedTurn, + gateReply: text.includes("gate-ok"), retryAfterPresent: response.headers["retry-after"] !== undefined, + genericRateLimit: response.statusCode === 429 && error?.["type"] === "rate_limit_error" && error["message"] === "Error" }); + })().catch(reject); + }); + outgoing.on("error", reject); + }); + const results = [await probe("native_identity_retained", retained)]; + // This diagnoses acceptance, not exact task following: retain gateReply separately. + // Require real text and an end_turn terminal; 200 alone or a truncated response is insufficient. + const passed = (result: Observation) => result.httpStatus === 200 && result.completed && result.endedTurn && result.textCharacters > 0; + if (passed(results[0]!)) { + results.push(await probe("native_identity_removed", removed)); + // Restore the same native-generated request to rule out a transient quota transition. + // This is a paired diagnostic control, not automatic inference retry or billing fallback. + results.push(await probe("native_identity_retained_again", retained)); + } + return { schemaVersion: 1, nativeControl: true, + capturedMaxTokens: capture.body["max_tokens"], + reproduced: results.length === 3 && passed(results[0]!) && results[1]!.genericRateLimit && passed(results[2]!), + reverseGatePassed: false, results }; + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const report = await runClaudeSystemControl(); + console.log(JSON.stringify(report)); process.exitCode = report.reproduced ? 0 : 1; + } catch { console.log(JSON.stringify({ schemaVersion: 1, reproduced: false, code: "system_control_unavailable" })); process.exitCode = 2; } +} diff --git a/e2e/gates/claude-tool-contract.ts b/e2e/gates/claude-tool-contract.ts new file mode 100644 index 0000000..bec0220 --- /dev/null +++ b/e2e/gates/claude-tool-contract.ts @@ -0,0 +1,67 @@ +/** Experimental direct tool contract. Native preamble is captured, never synthesized or persisted. */ +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { withNativeClaudeCapture } from "./claude-system-control.js"; +import { object } from "./probe.js"; + +export async function runClaudeToolContract() { + return withNativeClaudeCapture(async (capture, originalHeaders) => { + const system = (capture.body["system"] as unknown[]).filter(value => { + const text = object(value)?.["text"]; + return typeof text === "string" && /^(You are Claude Code,|You are a Claude agent,)/.test(text); + }); + if (system.length !== 1) throw new Error("native_identity_missing"); + const headers = { ...originalHeaders, "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" }; + const results: { stage: string; httpStatus: number; valid: boolean; errorType?: string }[] = []; + const post = async (stage: string, body: Record) => { + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", headers, body: JSON.stringify(body), redirect: "error", signal: AbortSignal.timeout(30000), + }); + const reader = response.body?.getReader(); + if (!reader) throw new Error("missing_body"); + const parts: Buffer[] = []; let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); if (done) break; + size += value.byteLength; if (size > 1024 * 1024) throw new Error("body_limit"); parts.push(Buffer.from(value)); + } + } finally { await reader.cancel(); } + const parsed = object(JSON.parse(Buffer.concat(parts).toString("utf8"))); + const type = object(parsed?.["error"])?.["type"]; + results.push({ stage, httpStatus: response.status, valid: false, + ...(typeof type === "string" && ["rate_limit_error", "invalid_request_error", "authentication_error"].includes(type) ? { errorType: type } : {}) }); + return response.ok ? parsed : undefined; + }; + const request = { + model: "claude-sonnet-5", max_tokens: 1024, + system: [...system, { type: "text", text: "Execute the supplied compatibility task. Use only the supplied tools. Preserve the user's instructions and report the tool result exactly." }], + messages: [{ role: "user", content: "Call read_fixture with path check.txt, then report its returned content exactly. You do not know the content until the tool returns." }], + tools: [{ name: "read_fixture", description: "Read a named fixture in the isolated working directory.", + input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"], additionalProperties: false } }], + tool_choice: { type: "tool", name: "read_fixture" }, + }; + const first = await post("tool_call", request); + const calls = Array.isArray(first?.["content"]) ? first["content"].map(object).filter(block => block?.["type"] === "tool_use") : []; + const call = calls[0]; + if (first?.["stop_reason"] !== "tool_use" || calls.length !== 1 || call?.["name"] !== "read_fixture" || + typeof call["id"] !== "string" || object(call["input"])?.["path"] !== "check.txt") { + return { schemaVersion: 1, success: false, nativePreambleUsed: true, results }; + } + results[0]!.valid = true; + // The result is generated only after the requested tool has been validated. + const value = `tool-result-${randomUUID()}`; + const next = await post("tool_continuation", { ...request, tool_choice: { type: "none" }, + messages: [...request.messages, { role: "assistant", content: first["content"] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: call["id"], content: value }] }], + }); + const text = Array.isArray(next?.["content"]) ? next["content"].map(object).filter(block => block?.["type"] === "text") + .map(block => block?.["text"]).join("") : ""; + results[1]!.valid = next?.["stop_reason"] === "end_turn" && text === value; + return { schemaVersion: 1, success: results.every(result => result.valid), nativePreambleUsed: true, results }; + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { const report = await runClaudeToolContract(); console.log(JSON.stringify(report)); process.exitCode = report.success ? 0 : 1; } + catch { console.log(JSON.stringify({ schemaVersion: 1, success: false, code: "tool_contract_unavailable" })); process.exitCode = 2; } +} diff --git a/e2e/gates/contracts.ts b/e2e/gates/contracts.ts new file mode 100644 index 0000000..2171823 --- /dev/null +++ b/e2e/gates/contracts.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +/** Native tool schema from an isolated fake-upstream run. All message/history values are fabricated. */ +// An empty list is the native protocol marker. The similarly named strings in +// Codex's source are log-redaction labels, not wire values. +export const PLAINTEXT_ARGUMENTS: readonly string[] = Object.freeze([]); +export const TASK = "Reply with gate-ok."; + +interface NativeNamespace { + type: string; + name: string; + tools: { name: string; parameters: { properties: Record } }[]; +} + +export const collaborationTools = (plaintext = true): NativeNamespace => { + const namespace: NativeNamespace = JSON.parse(readFileSync(new URL( + "../../test/fixtures/native/codex-0.153.3-collaboration.json", import.meta.url, + ), "utf8")); + if (plaintext) for (const tool of namespace.tools) { + if (["spawn_agent", "send_message", "followup_task"].includes(tool.name)) { + const message = tool.parameters.properties["message"]; + if (!message || message.encrypted !== true) throw new Error("native_fixture_drift"); + message.encrypted = false; + } + } + return namespace; +}; + +export const openaiSchemaRequest = (model: string, plaintext = true) => ({ + model, + input: [ + { type: "additional_tools", id: "at_subswitch_gate", role: "developer", tools: [collaborationTools(plaintext)] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "Say gate-ok. Do not call tools." }] }, + ], + tool_choice: "auto", + parallel_tool_calls: false, + store: false, + stream: true, +}); + +export const openaiArgumentsRequest = (model: string) => { + const request = openaiSchemaRequest(model); + request.input[1] = { + type: "message", role: "user", content: [{ type: "input_text", text: + `Call collaboration.spawn_agent once with task_name probe and message exactly ${JSON.stringify(TASK)}.` }], + }; + return request; +}; + +export const openaiSchemaFieldRequest = ( + model: string, name: "spawn_agent" | "send_message" | "followup_task", mode: "false" | "omit", +) => { + const request = openaiSchemaRequest(model, false); + const namespace = request.input[0]!.tools![0]!; + const message = namespace.tools.find((tool) => tool.name === name)?.parameters.properties["message"]; + if (!message || message.encrypted !== true) throw new Error("native_fixture_drift"); + if (mode === "omit") delete message.encrypted; + else message.encrypted = false; + return request; +}; + +export const openaiNativeArgumentsRequest = (model: string) => { + const request = openaiSchemaRequest(model, false); + request.input[1] = { type: "message", role: "user", content: [{ type: "input_text", text: + `Call collaboration.spawn_agent once with task_name probe, model claude-sonnet-5, fork_turns none, and message exactly ${JSON.stringify(TASK)}.` }] }; + return request; +}; + +export const openaiMarkerRequest = (model: string, plaintextSchema = true) => ({ + ...openaiSchemaRequest(model, plaintextSchema), + input: [ + { type: "additional_tools", id: "at_subswitch_gate", role: "developer", tools: [collaborationTools(plaintextSchema)] }, + { type: "message", role: "user", content: "Run the fixed compatibility check." }, + { + type: "function_call", id: "fc_subswitch_gate", call_id: "call_subswitch_gate", + namespace: "collaboration", name: "spawn_agent", + arguments: JSON.stringify({ task_name: "probe", message: TASK }), + encrypted_function_args: PLAINTEXT_ARGUMENTS, + }, + { + type: "function_call_output", call_id: "call_subswitch_gate", + output: JSON.stringify({ agent_id: "probe", task_name: "/root/probe" }), + }, + { + type: "agent_message", id: "amsg_subswitch_gate", author: "/root/probe", recipient: "/root", + content: [{ type: "input_text", text: "gate-ok" }], + }, + { type: "message", role: "user", content: "The fabricated probe agent has finished. Reply with gate-ok and do not call tools." }, + ], +}); + +export const claudeToolRequest = (model: string) => ({ + model, + max_tokens: 128, + messages: [{ role: "user", content: "Call echo with text gate-ok." }], + tools: [{ + name: "echo", description: "Return a fixed compatibility test value.", + input_schema: { + type: "object", properties: { text: { type: "string" } }, + required: ["text"], additionalProperties: false, + }, + }], + tool_choice: { type: "tool", name: "echo" }, +}); diff --git a/e2e/gates/credentials.ts b/e2e/gates/credentials.ts new file mode 100644 index 0000000..569d8a3 --- /dev/null +++ b/e2e/gates/credentials.ts @@ -0,0 +1,95 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { object, type GateProvider, type GateAuth } from "./probe.js"; + +const exec = promisify(execFile); + +export class CredentialUnavailable extends Error { + constructor(readonly code: string) { super(code); } +} + +export interface CredentialOptions { + readonly provider: GateProvider; + readonly auth: GateAuth; + readonly envName?: string; +} + +export interface CredentialDeps { + readonly env: NodeJS.ProcessEnv; + readonly home: string; + readonly platform: NodeJS.Platform; + readonly read: (path: string) => Promise; + readonly keychain: (service: string) => Promise; + readonly now: () => number; +} + +const actualDeps: CredentialDeps = { + env: process.env, home: homedir(), platform: process.platform, + read: (path) => readFile(path, "utf8"), now: Date.now, + keychain: async (service) => (await exec("security", ["find-generic-password", "-s", service, "-w"], { + timeout: 10_000, maxBuffer: 1024 * 1024, + })).stdout, +}; + +const nonempty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; + +/** Read-only gate discovery; expired credentials require the native client to refresh. */ +export async function probeHeaders(options: CredentialOptions, deps = actualDeps): Promise> { + const headers: Record = { + "content-type": "application/json", "user-agent": "subswitch-compatibility-probe/0.4.0", + accept: options.provider === "openai" ? "text/event-stream" : "application/json", + }; + if (options.provider === "claude") headers["anthropic-version"] = "2023-06-01"; + if (options.auth === "api") { + const name = options.envName ?? (options.provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"); + const token = deps.env[name]; + if (!nonempty(token)) throw new CredentialUnavailable("api_key_env_missing"); + headers[options.provider === "openai" ? "authorization" : "x-api-key"] = + options.provider === "openai" ? `Bearer ${token}` : token; + return headers; + } + if (options.envName !== undefined) throw new CredentialUnavailable("env_requires_api_mode"); + try { + if (options.provider === "openai") { + const raw = await deps.read(join(deps.env["CODEX_HOME"] ?? join(deps.home, ".codex"), "auth.json")); + const tokens = object(object(JSON.parse(raw))?.["tokens"]); + const token = tokens?.["access_token"]; + if (!nonempty(token)) throw new CredentialUnavailable("codex_subscription_missing"); + let claims: Record | undefined; + try { claims = object(JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"))); } + catch { /* Native files can contain an opaque access token with an explicit account ID. */ } + if (typeof claims?.["exp"] === "number" && claims["exp"] * 1000 <= deps.now()) { + throw new CredentialUnavailable("codex_subscription_expired"); + } + const account = tokens?.["account_id"] ?? object(claims?.["https://api.openai.com/auth"])?.["chatgpt_account_id"]; + if (!nonempty(account)) throw new CredentialUnavailable("codex_account_missing"); + headers["authorization"] = `Bearer ${token}`; + headers["chatgpt-account-id"] = account; + headers["openai-beta"] = "responses=experimental"; + } else { + const configDir = deps.env["CLAUDE_CONFIG_DIR"]; + const directory = configDir ?? join(deps.home, ".claude"); + // Native Claude Code isolates Keychain services for explicit configuration dirs. + const service = "Claude Code-credentials" + (configDir === undefined ? "" : + `-${createHash("sha256").update(resolve(configDir)).digest("hex").slice(0, 8)}`); + const raw = deps.platform === "darwin" ? await deps.keychain(service) : + await deps.read(join(directory, ".credentials.json")); + const oauth = object(object(JSON.parse(raw))?.["claudeAiOauth"]); + if (!nonempty(oauth?.["accessToken"])) throw new CredentialUnavailable("claude_subscription_missing"); + if (typeof oauth["expiresAt"] !== "number" || oauth["expiresAt"] <= deps.now()) { + throw new CredentialUnavailable("claude_subscription_expired"); + } + headers["authorization"] = `Bearer ${oauth["accessToken"]}`; + headers["anthropic-beta"] = "oauth-2025-04-20"; + } + } catch (error) { + if (error instanceof CredentialUnavailable) throw error; + throw new CredentialUnavailable(options.provider === "claude" && deps.platform === "darwin" ? + "claude_keychain_unavailable" : "credential_file_unavailable"); + } + return headers; +} diff --git a/e2e/gates/live-reverse.md b/e2e/gates/live-reverse.md new file mode 100644 index 0000000..53058b5 --- /dev/null +++ b/e2e/gates/live-reverse.md @@ -0,0 +1,116 @@ +# Live native Codex → Claude prototype + +This records the prototype stage. The [production parity gateway](production-parity.md) +now implements the scoped feature in `serve`, setup and diagnostics, including real +native authentication and model discovery. + +Verified 2026-09-07, Asia/Jerusalem, with native Codex 0.153.4. The original +live-provider question now has a positive result: an OpenAI parent can spawn a +Claude-backed native Codex child, Codex executes its tool, Claude consumes the +result, and the answer reaches the parent. A follow-up on the same child works. + +This is an isolated source-only prototype, **not production reverse support**. + +## Reproduction + +```sh +# Direct subscription tool call and unpredictable tool-result continuation: +node --import tsx e2e/gates/claude-tool-contract.ts + +# Actual native Codex with a scripted parent and live Claude child: +node --import tsx e2e/gates/native-reverse.ts + +# Live OpenAI parent + live Claude child, including a follow-up: +node --import tsx e2e/gates/native-reverse.ts --live-parent --followup +node --import tsx e2e/gates/native-reverse.ts --live-parent --http --followup + +# Native Codex itself authenticated through the existing subscription: +node --import tsx e2e/gates/native-reverse.ts --live-parent --followup --native-subscription +``` + +The ordinary runner uses a fabricated API credential between native Codex and the +local test proxy. Both actual upstreams use explicitly selected subscription +credentials. `--native-subscription` additionally puts the native Codex client in +subscription mode, using a restricted temporary copy of its existing credentials +with the refresh token removed. It uses the subscription ingress path. Neither +variant selects API billing or introduces another login. + +## Results + +| Check | Result | +| --- | --- | +| Direct Claude function call and result continuation | Pass; both requests HTTP 200, correct tool/arguments and exact unpredictable returned value | +| Native v2, scripted parent, live Claude, WebSockets | Pass; two Claude requests, native tool result and parent delivery | +| Native v2, scripted parent, live Claude, HTTP fallback | Pass; five HTTP requests through the ingress | +| Native v2, live OpenAI parent + live Claude child, WebSockets | Pass; tool/result, child answer and parent delivery | +| Live parent/child with same-child follow-up, WebSockets | Pass; five OpenAI requests, four Claude requests, two WebSocket connections | +| Live parent/child with same-child follow-up, HTTP fallback | Pass; nine HTTP requests, five OpenAI and four Claude requests | +| Native subscription authentication, live parent/child + follow-up, WebSockets | Pass; native Codex exit 0, all nine upstream calls HTTP 200 | +| Existing Claude Code → OpenAI flow, concurrent with reverse HTTP follow-up | Pass; two translated requests and real Read continuation | + +Each child must read an unpredictable value from `check.txt` using its native +code/tool surface. Follow-up tests require reading a second unpredictable value +from `followup.txt`. The values are absent from prompts. Success requires seeing +the native tool result, Claude's answer, and delivery to the parent; HTTP 200 alone +does not count. All models and tool execution are real in `--live-parent` runs. +Model catalogs and role configuration are isolated test fixtures, not production +model discovery. + +## Implementation and corrections + +- [Request/response adapter](reverse-adapter.ts): namespaced functions and freeform + tools use deterministic wire names; original call names/types are restored for + Codex execution. Instructions and message text are not renamed or moved to + lower-priority user turns. Unsupported content/history produces explicit errors. +- [Thinking state](reverse-state.ts): AES-256-GCM envelopes carry exact Claude + thinking/signatures and their associated output through opaque Responses items. + The adapter verifies replayed output identities before restoring the original + assistant blocks. Wrong keys and altered history fail explicitly. +- The first native run stopped on an unhandled thinking block. Sonnet 5 enables + adaptive thinking by default; the adapter now preserves those blocks, including + empty visible thinking with a signature. [Claude migration documentation](https://platform.claude.com/docs/en/models/sonnet-5/migration-guide) +- A later run reached the child answer but failed parent delivery because the + emitted Responses usage object lacked required fields. Correct input/output/total + counts, including cache accounting, fixed the retries and delivery failure. + +The upstream calls are currently buffered before native events are emitted. +This proves protocol/tool orchestration, not production incremental streaming. +The state key and continuation map belong to the isolated run. A fresh codec with +the same key is covered by tests, but real proxy restart/session resume is not yet +implemented or certified. + +## Identity and isolation + +An initial isolated native Claude control supplies its identity system preamble +in memory. The direct Claude adapter retains that preamble and supplies the Codex +task, tool definitions, and conversation. It does not synthesize a billing marker, +copy a native user-agent, or rewrite product names in instructions. Native Claude +does not execute the Codex child's tools or run its agent loop. + +The working path still makes a native identity claim. The original plan excluded +spoofing workarounds, so whether to include this explicitly documented behavior in +production was asked as a scope decision. No answer has been assumed. These live +results do not establish Anthropic's support commitment or which quota was billed. + +All client configuration and test files are temporary, with separate loopback +ports and bounded processes. Captures, credentials, tool output and signatures +are not logged. The real shared credential stores and running user sessions are +left unchanged. Production `serve` still exposes only the opt-in raw Codex ingress. + +## Remaining work + +1. Resolve production subscription identity behavior against the original scope. +2. Production provider authentication/refresh, ingress-aware registry and discovery. +3. Complete translation coverage, incremental streaming, cancellation, typed errors + and resource bounds across HTTP and WebSockets. +4. Durable keys/continuations, real restart/resume and compaction, including + cross-provider opaque-history boundaries. +5. Setup/undo, global config layering, diagnostics, and native v1/v2 release matrix. +6. Explicit API-mode validation with real API credentials; no such billing mode has + been selected or silently substituted in these tests. + +Verification: typechecking, all **793 tests**, and the packaged-install smoke test +pass. The eight added tests cover tool round trips, namespace collisions, +malformed/truncated output, opaque state, image results, usage/events, authenticated +thinking replay and reasoning effort. Source-only probes are excluded from the +published tarball. These checks do not make the unfinished production feature ready. diff --git a/e2e/gates/namespace-adapter.ts b/e2e/gates/namespace-adapter.ts new file mode 100644 index 0000000..df8f02a --- /dev/null +++ b/e2e/gates/namespace-adapter.ts @@ -0,0 +1 @@ +export * from "../../src/collaboration-compat.js"; diff --git a/e2e/gates/native-claude.ts b/e2e/gates/native-claude.ts new file mode 100644 index 0000000..f243eb5 --- /dev/null +++ b/e2e/gates/native-claude.ts @@ -0,0 +1,129 @@ +/** Live native-Claude control. Does not implement or certify direct subscription inference. */ +import http from "node:http"; +import https from "node:https"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../../src/config.js"; +import { buildDeps, createProxyServer, listenServer } from "../../src/server.js"; +import { filterRawHeaders, HOP_BY_HOP, RESPONSE_STRIP, setRawRequestHeaders } from "../../src/raw-http-passthrough.js"; +import { probeHeaders } from "./credentials.js"; +import { nativeProcess, isolatedNativeEnv } from "./native-process.js"; +import { object } from "./probe.js"; + +export async function runNativeClaude(destination: "claude" | "openai" = "claude") { + const headers = await probeHeaders({ provider: "claude", auth: "subscription" }); + const openaiHeaders = destination === "openai" ? await probeHeaders({ provider: "openai", auth: "subscription" }) : undefined; + const temp = await mkdtemp(join(tmpdir(), "subswitch-claude-contract-")); + const work = join(temp, "work"), configDir = join(temp, "config"); + const secretTestValue = `native-read-${randomUUID()}`; + const observations = { messages: 0, sonnetRequests: 0, nativeAgentCall: false, + nativeReadCall: false, toolResultContinuation: false, upstreamStatuses: [] as number[], translatedRequests: 0 }; + const observe = (raw: Buffer) => { + try { + observations.messages++; + const body = object(JSON.parse(raw.toString("utf8"))); + if (body?.["model"] === "claude-sonnet-5") observations.sonnetRequests++; + if (Array.isArray(body?.["messages"])) for (const message of body["messages"]) { + const content = object(message)?.["content"]; + if (!Array.isArray(content)) continue; + for (const value of content) { + const block = object(value); + if (block?.["type"] === "tool_use" && block["name"] === "Agent") observations.nativeAgentCall = true; + if (block?.["type"] === "tool_use" && block["name"] === "Read") observations.nativeReadCall = true; + if (block?.["type"] === "tool_result" && JSON.stringify(block["content"]).includes(secretTestValue)) + observations.toolResultContinuation = true; + } + } + } catch { /* In-memory structural observation only; no response changes. */ } + }; + const outgoing = new Set(); + const relay = http.createServer(async (req, res) => { + try { + if (req.url === "/disabled-refresh") { res.writeHead(400); res.end(); return; } + const chunks: Buffer[] = []; let size = 0; + for await (const chunk of req) { size += chunk.length; if (size > 4 * 1024 * 1024) throw new Error(); chunks.push(chunk); } + const raw = Buffer.concat(chunks); + if (observations.messages > 12) { res.writeHead(400); res.end(); return; } + const upstream = https.request({ hostname: "api.anthropic.com", method: req.method, path: req.url, + signal: AbortSignal.timeout(30000) }, (response) => { + observations.upstreamStatuses.push(response.statusCode ?? 502); + res.writeHead(response.statusCode ?? 502, filterRawHeaders(response.rawHeaders, RESPONSE_STRIP)); + response.pipe(res); + }); + outgoing.add(upstream); + upstream.on("close", () => outgoing.delete(upstream)); + upstream.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); }); + setRawRequestHeaders(upstream, filterRawHeaders(req.rawHeaders, HOP_BY_HOP)); + upstream.end(raw); + } catch { if (!res.headersSent) res.writeHead(400); res.end(); } + }); + let proxy: http.Server | undefined; + try { + await mkdir(work, { mode: 0o700 }); + await mkdir(configDir, { mode: 0o700 }); + await writeFile(join(work, "check.txt"), secretTestValue, { mode: 0o600 }); + if (!(await listenServer(relay, 0, "127.0.0.1")).ok) throw new Error(); + const address = relay.address(); + if (!address || typeof address === "string") throw new Error(); + const authFile = join(temp, "isolated-openai-auth.json"); + if (openaiHeaders) await writeFile(authFile, JSON.stringify({ tokens: { + access_token: openaiHeaders["authorization"]!.slice("Bearer ".length), + account_id: openaiHeaders["chatgpt-account-id"], refresh_token: "", + } }), { mode: 0o600 }); + const config = loadConfig({ env: {}, configPath: join(temp, "inline.json"), readFile: () => JSON.stringify({ + anthropic: { baseUrl: `http://127.0.0.1:${address.port}` }, + providers: { codex: { authFile, oauthTokenUrl: `http://127.0.0.1:${address.port}/disabled-refresh` } }, + }) }); + if (!config.ok) throw new Error(); + const deps = buildDeps(config.value.config, { log: (_level, event, fields) => { + if (event === "request_complete" && fields?.route?.startsWith("codex:")) observations.translatedRequests++; + } }); + if (!deps.ok) throw new Error(); + proxy = createProxyServer(deps.value); + proxy.prependListener("request", (req: http.IncomingMessage) => { + if (!req.url?.startsWith("/v1/messages")) return; + const parts: Buffer[] = []; let bytes = 0; + req.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes <= 4 * 1024 * 1024) parts.push(chunk); }); + req.on("end", () => { if (bytes <= 4 * 1024 * 1024) observe(Buffer.concat(parts)); }); + }); + if (!(await listenServer(proxy, 0, "127.0.0.1")).ok) throw new Error(); + const proxyAddress = proxy.address(); + if (!proxyAddress || typeof proxyAddress === "string") throw new Error(); + const env = isolatedNativeEnv({ + CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_OAUTH_TOKEN: headers["authorization"]!.slice("Bearer ".length), + ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyAddress.port}`, + }); + const result = await nativeProcess("claude", [ + "--print", "--setting-sources", "", "--settings", '{"disableAllHooks":true}', + "--disable-slash-commands", "--strict-mcp-config", "--no-session-persistence", + "--tools", "Agent,Read", "--allowedTools", "Agent,Read", "--permission-mode", "dontAsk", + "--model", "sonnet", "--output-format", "json", + "--agents", JSON.stringify({ sonnet_probe: { + description: "Read the isolated contract fixture.", model: destination === "openai" ? "gpt-5.5" : "sonnet", tools: ["Read"], + prompt: "Read check.txt with the Read tool and report its exact content. Do not use other tools.", + } }), + "Delegate to sonnet_probe to read check.txt. Return the exact value reported by that child. Do not read it yourself.", + ], { cwd: work, env, timeoutMs: 60000 }); + let answer: Record | undefined; + try { answer = object(JSON.parse(result.stdout)); } catch { /* Never emit CLI output. */ } + return { schemaVersion: 1, destination, success: result.code === 0 && !result.failure && answer?.["is_error"] === false && + typeof answer["result"] === "string" && answer["result"].includes(secretTestValue) && + observations.nativeAgentCall && observations.nativeReadCall && observations.toolResultContinuation && + (destination !== "openai" || observations.translatedRequests >= 2), + nativeExit: result.failure ?? `exit_${result.code}`, ...observations }; + } finally { + for (const request of outgoing) request.destroy(); + proxy?.closeAllConnections(); + await new Promise((resolve) => { if (proxy) proxy.close(() => resolve()); else resolve(); }); + relay.closeAllConnections(); + await new Promise((resolve) => relay.close(() => resolve())); + await rm(temp, { recursive: true, force: true }); + } +} + +if (process.argv[1]?.endsWith("native-claude.ts")) { + try { const report = await runNativeClaude(process.argv.includes("--openai") ? "openai" : "claude"); console.log(JSON.stringify(report)); process.exitCode = report.success ? 0 : 1; } + catch { console.log(JSON.stringify({ schemaVersion: 1, success: false, code: "native_control_unavailable" })); process.exitCode = 2; } +} diff --git a/e2e/gates/native-codex.ts b/e2e/gates/native-codex.ts new file mode 100644 index 0000000..33e519c --- /dev/null +++ b/e2e/gates/native-codex.ts @@ -0,0 +1,308 @@ +/** Isolated native-client contract test. Every upstream response and credential is fabricated. */ +import http from "node:http"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gunzipSync, brotliDecompressSync, inflateSync, zstdDecompressSync } from "node:zlib"; +import { WebSocketServer } from "ws"; +import { loadConfig } from "../../src/config.js"; +import { buildDeps, createProxyServer, listenServer } from "../../src/server.js"; +import { object } from "./probe.js"; +import { nativeProcess, isolatedNativeEnv } from "./native-process.js"; +import { BRIDGE_NAMESPACE, namespaceRequest, namespaceEvent } from "./namespace-adapter.js"; + +const PARENT = "gpt-6-astra"; +const CHILD = "claude-sonnet-5"; +const marker = "native-tool-ok"; +type Item = Record; + +function events(id: string, output: Item[]): Item[] { + const response = { id, object: "response", created_at: 0, status: "in_progress", output: [] }; + const frames: Item[] = [{ type: "response.created", response }, { type: "response.in_progress", response }]; + for (const [output_index, item] of output.entries()) { + frames.push({ type: "response.output_item.added", output_index, item: { + ...item, status: "in_progress", ...(item["type"] === "function_call" ? { arguments: "" } : {}), + } }); + if (item["type"] === "message") frames.push({ + type: "response.output_text.delta", output_index, content_index: 0, item_id: item["id"], + delta: (item["content"] as Item[])[0]?.["text"], + }); + if (item["type"] === "function_call") { + frames.push({ type: "response.function_call_arguments.delta", output_index, item_id: item["id"], delta: item["arguments"] }); + frames.push({ type: "response.function_call_arguments.done", output_index, item_id: item["id"], arguments: item["arguments"] }); + } + frames.push({ type: "response.output_item.done", output_index, item }); + } + frames.push({ type: "response.completed", response: { ...response, status: "completed", output, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } }); + return frames.map((frame, sequence_number) => ({ ...frame, sequence_number })); +} + +const message = (id: string, text = marker): Item => ({ + type: "message", id, role: "assistant", status: "completed", phase: "final_answer", + content: [{ type: "output_text", text, annotations: [] }], +}); +const call = (id: string, name: string, args: Item, namespace?: string): Item => ({ + type: "function_call", id: `fc_${id}`, call_id: id, name, status: "completed", + ...(namespace ? { namespace } : {}), arguments: JSON.stringify(args), + // These arguments are authored by the fixture, never relabeled upstream ciphertext. + ...(namespace === "collaboration" ? { encrypted_function_args: [] } : {}), +}); + +const successfulExec = (value: unknown, depth = 0): boolean => { + if (depth > 8) return false; + if (typeof value === "string") { + try { return successfulExec(JSON.parse(value), depth + 1); } catch { + return /Process exited with code 0[\s\S]*Final output:\s*native-tool-ok/.test(value); + } + } + if (Array.isArray(value)) return value.some((entry) => successfulExec(entry, depth + 1)); + const entry = object(value); + if (!entry) return false; + if (entry["exit_code"] === 0 && typeof entry["output"] === "string" && entry["output"].includes(marker)) return true; + return ["content", "output", "text"].some((key) => successfulExec(entry[key], depth + 1)); +}; + +/** Fake catalog uses native public metadata to keep the installed client's defaults valid. */ +async function catalog(version: "v1" | "v2"): Promise { + const native: Item = JSON.parse(await readFile(new URL("../../test/fixtures/native/codex-0.153.3-model.json", import.meta.url), "utf8")); + return [PARENT, CHILD].map((slug) => ({ + ...native, slug, display_name: slug, description: "Fabricated native contract model", + model_messages: null, base_instructions: "Perform the fixed native contract check. Follow the provided tool definitions.", + supported_in_api: true, multi_agent_version: version, + })); +} + +export async function runNativeCodex(version: "v1" | "v2" = "v2", transport: "websocket" | "http" = "websocket", adaptNamespace = false) { + const collaborationNamespace = adaptNamespace ? BRIDGE_NAMESPACE : "collaboration"; + const models = await catalog(version); + const temp = await mkdtemp(join(tmpdir(), "subswitch-codex-contract-")); + const codexDir = join(temp, "codex"); + const work = join(temp, "work"); + const observations = { + version, transport, adaptNamespace, clientVersion: "unknown", websocketConnections: 0, httpResponses: 0, warmups: 0, continuations: 0, compressedRequests: 0, + parentRequests: 0, childRequests: 0, nativeToolExecuted: false, childReplyDelivered: false, + nativeV2Schema: false, unknownModel: false, fixtureErrors: 0, + execToolType: "missing", stage: "setup", nativeExit: "not_started", nativeDiagnostic: "none", + nativeEventTypes: [] as string[], nativeStderrBytes: 0, nativeStdoutBytes: 0, multiplexed: false, + followupReceived: false, followupReplyDelivered: false, + childTaskReadable: false, childIdentityPreserved: false, replyIdentityPreserved: false, + }; + const history = new Map(); + let serial = 0; + let childStarted = false; + let childCalled = false; + let parentSpawned = false; + let parentWaited = false; + let parentFollowedUp = false; + let parentFollowupWaited = false; + const respond = (body: Item): Item[] => { + const id = `resp_native_${++serial}`; + if (serial > 24) throw new Error("fixture_request_limit"); + const previous = body["previous_response_id"]; + if (typeof previous === "string" && !history.has(previous)) throw new Error("fixture_missing_state"); + if (typeof previous === "string") observations.continuations++; + const input = [...(typeof previous === "string" ? history.get(previous) ?? [] : []), + ...(Array.isArray(body["input"]) ? body["input"] as Item[] : [])]; + const toolSources = [...(Array.isArray(body["tools"]) ? body["tools"] as Item[] : []), + ...input.flatMap((item) => item["type"] === "additional_tools" && Array.isArray(item["tools"]) ? item["tools"] as Item[] : [])]; + if (toolSources.some((t) => t["name"] === collaborationNamespace)) observations.nativeV2Schema = true; + const functions = toolSources.find((t) => t["name"] === "functions"); + const execTool = Array.isArray(functions?.["tools"]) ? functions["tools"].find((t: Item) => t["name"] === "exec") : undefined; + if (execTool) observations.execToolType = execTool["type"] === "custom" ? "custom" : "function"; + if (body["generate"] === false) { + observations.warmups++; history.set(id, input); + return events(id, []); + } + let output: Item[]; + if (body["model"] === CHILD) { + childStarted = true; + observations.childRequests++; + observations.childTaskReadable ||= input.some((item) => item["type"] === "agent_message" && + JSON.stringify(item["content"]).includes("Run the fixed tool check")); + observations.childIdentityPreserved ||= input.some((item) => item["type"] === "agent_message" && + item["author"] === "/root" && item["recipient"] === "/root/sonnet"); + if (input.some((item) => item["type"] === "agent_message" && JSON.stringify(item["content"]).includes("Repeat the fixed check"))) { + observations.followupReceived = true; + output = [message("msg_child_followup", "native-followup-ok")]; + } else if (!childCalled) { + childCalled = true; + output = execTool?.["type"] === "custom" ? [{ + type: "custom_tool_call", id: "ctc_native_exec", call_id: "native_exec", namespace: "functions", name: "exec", + input: `const r = await tools.exec_command({cmd: "printf '${marker}'", login: false, max_output_tokens: 32}); text(r);`, + }] : [call("native_exec", "exec_command", { cmd: `printf '${marker}'`, login: false, max_output_tokens: 32 }, "functions")]; + } else { + observations.nativeToolExecuted = input.some((item) => ["function_call_output", "custom_tool_call_output"].includes(String(item["type"])) && + item["call_id"] === "native_exec" && successfulExec(item["output"])); + output = [message("msg_child")]; + } + } else if (body["model"] === PARENT) { + observations.parentRequests++; + observations.replyIdentityPreserved ||= input.some((item) => item["type"] === "agent_message" && + item["author"] === "/root/sonnet" && item["recipient"] === "/root"); + if (!parentSpawned) { + parentSpawned = true; + output = [version === "v2" ? call("native_spawn", "spawn_agent", { + task_name: "sonnet", message: "Run the fixed tool check, then reply with native-tool-ok.", + model: CHILD, fork_turns: "none", + }, collaborationNamespace) : call("native_spawn", "spawn_agent", { message: "Run the fixed tool check, then reply with native-tool-ok.", agent_type: "sonnet" }, "multi_agent_v1")]; + } else if (!parentWaited) { + parentWaited = true; + const result = input.find((item) => ["function_call_output", "custom_tool_call_output"].includes(String(item["type"])) && item["call_id"] === "native_spawn"); + let childId: unknown; + try { childId = object(JSON.parse(String(result?.["output"])))?.["agent_id"]; } catch { /* Checked by the final report. */ } + if (!childId) childId = JSON.stringify(result?.["output"])?.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0]; + output = [version === "v2" ? call("native_wait", "wait_agent", { timeout_ms: 10000 }, collaborationNamespace) : + call("native_wait", "wait_agent", { targets: [childId], timeout_ms: 10000 }, "multi_agent_v1")]; + } else { + observations.childReplyDelivered = input.some((item) => + (item["type"] === "agent_message" || (["function_call_output", "custom_tool_call_output"].includes(String(item["type"])) && item["call_id"] === "native_wait")) && + JSON.stringify(item).includes(marker)); + if (version === "v2" && !parentFollowedUp) { + parentFollowedUp = true; + output = [call("native_followup", "followup_task", { target: "sonnet", message: "Repeat the fixed check and return native-followup-ok." }, collaborationNamespace)]; + } else if (version === "v2" && !parentFollowupWaited) { + parentFollowupWaited = true; + output = [call("native_followup_wait", "wait_agent", { timeout_ms: 10000 }, collaborationNamespace)]; + } else { + observations.followupReplyDelivered = input.some((item) => item["type"] === "agent_message" && JSON.stringify(item["content"]).includes("native-followup-ok")); + output = [message("msg_parent")]; + } + } + } else { observations.unknownModel = true; output = [message("msg_unknown")]; } + history.set(id, [...input, ...output]); + return events(id, output); + }; + const exchange = (body: Item): Item[] => adaptNamespace ? + respond(namespaceRequest(body)).map(namespaceEvent) : respond(body); + + const wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 }); + const upstream = http.createServer(async (req, res) => { + try { + if (req.url?.includes("/models")) { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ models })); return; } + if (!req.url?.includes("/responses")) { res.writeHead(404); res.end(); return; } + const chunks: Buffer[] = []; let size = 0; + for await (const chunk of req) { size += chunk.length; if (size > 4 * 1024 * 1024) throw new Error(); chunks.push(chunk); } + observations.httpResponses++; + let raw: Buffer = Buffer.concat(chunks); + const decoders: Record Buffer> = { + gzip: gunzipSync, br: brotliDecompressSync, deflate: inflateSync, zstd: zstdDecompressSync, + }; + const encoding = req.headers["content-encoding"]; + if (typeof encoding === "string" && encoding !== "identity") { + const decode = decoders[encoding]; + if (!decode) throw new Error(); + raw = decode(raw, { maxOutputLength: 4 * 1024 * 1024 }); + observations.compressedRequests++; + } + const frames = exchange(JSON.parse(raw.toString("utf8"))); + res.setHeader("content-type", "text/event-stream"); + res.end(frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("")); + } catch { observations.fixtureErrors++; res.writeHead(400); res.end(); } + }); + upstream.on("upgrade", (req, socket, head) => { + if (transport === "http") { socket.end("HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; } + wss.handleUpgrade(req, socket, head, (ws) => { + observations.websocketConnections++; + ws.on("error", () => undefined); + ws.on("message", (data) => { + try { + const request = JSON.parse(data.toString()); + const streamId = typeof request.stream_id === "string" ? request.stream_id : undefined; + if (streamId) observations.multiplexed = true; + for (const frame of exchange(request)) ws.send(JSON.stringify({ ...frame, ...(streamId ? { stream_id: streamId } : {}) })); + } + catch { observations.fixtureErrors++; ws.close(1008, "fixture_contract_error"); } + }); + }); + }); + let proxy: http.Server | undefined; + try { + await mkdir(codexDir, { mode: 0o700 }); + await mkdir(work, { mode: 0o700 }); + const env = isolatedNativeEnv({ CODEX_HOME: codexDir, OPENAI_API_KEY: "fabricated-native-test-key" }); + const versionResult = await nativeProcess("codex", ["--version"], { cwd: work, env, timeoutMs: 10000 }); + const clientVersion = versionResult.stdout.match(/codex-cli (\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?)/)?.[1]; + if (versionResult.failure || versionResult.code !== 0 || !clientVersion) throw new Error("native_version_unavailable"); + observations.clientVersion = clientVersion; + const bound = await listenServer(upstream, 0, "127.0.0.1"); + if (!bound.ok) throw new Error("fixture_listen_failed"); + const address = upstream.address(); + if (!address || typeof address === "string") throw new Error(); + const base = `http://127.0.0.1:${address.port}`; + const loaded = loadConfig({ env: {}, configPath: join(temp, "inline.json"), readFile: () => JSON.stringify({ + anthropic: { baseUrl: base }, providers: { codex: { authFile: join(temp, "unused-auth.json") } }, + codexIngress: { enabled: true, subscriptionBaseUrl: `${base}/backend-api/codex`, apiBaseUrl: `${base}/v1` }, + }) }); + if (!loaded.ok) throw new Error("fixture_config_failed"); + const deps = buildDeps(loaded.value.config, { log: () => undefined }); + if (!deps.ok) throw new Error("fixture_proxy_failed"); + proxy = createProxyServer(deps.value); + const listening = await listenServer(proxy, 0, "127.0.0.1"); + if (!listening.ok) throw new Error("fixture_proxy_listen_failed"); + const proxyAddress = proxy.address(); + if (!proxyAddress || typeof proxyAddress === "string") throw new Error(); + await writeFile(join(codexDir, "auth.json"), JSON.stringify({ OPENAI_API_KEY: "fabricated-native-test-key" }), { mode: 0o600 }); + await writeFile(join(codexDir, "models_cache.json"), JSON.stringify({ + fetched_at: new Date().toISOString(), etag: "fixture", client_version: clientVersion, models, + }), { mode: 0o600 }); + await writeFile(join(codexDir, "sonnet.toml"), `model = "${CHILD}"\ndeveloper_instructions = "Perform the fixed native contract check."\n`, { mode: 0o600 }); + await writeFile(join(codexDir, "config.toml"), [ + `model = "${PARENT}"`, `openai_base_url = "http://127.0.0.1:${proxyAddress.port}/codex/v1"`, + 'approval_policy = "never"', 'sandbox_mode = "read-only"', + '[agents.sonnet]', 'description = "Fixed Sonnet contract check"', 'config_file = "sonnet.toml"', + ].join("\n"), { mode: 0o600 }); + observations.stage = "native_client"; + const result = await nativeProcess("codex", ["exec", "--skip-git-repo-check", "--ephemeral", "--ignore-rules", "--json", + "Perform the fixed native contract check using the sonnet child."], { + cwd: work, env, + }); + observations.nativeExit = result.failure ?? `exit_${result.code}`; + observations.nativeStdoutBytes = result.stdout.length; + observations.nativeStderrBytes = result.stderr.length; + observations.nativeDiagnostic = ["unsupported call", "failed to parse", "not found", "not allowed", "sandbox", "model", "permission", "fork_turns", "agent_type"] + .filter((code) => result.stderr.toLowerCase().includes(code)).join(",") || "none"; + observations.nativeEventTypes = result.stdout.split("\n").flatMap((line) => { + try { const type = JSON.parse(line).type; return ["thread.started", "item.started", "item.completed", "turn.started", "turn.completed", "turn.failed", "error"].includes(type) ? [type] : ["other"]; } + catch { return []; } + }); + const success = result.code === 0 && !result.failure && childStarted && observations.nativeToolExecuted && observations.childReplyDelivered && + result.stdout.includes(marker) && (version !== "v2" || + (observations.nativeV2Schema && observations.followupReceived && observations.followupReplyDelivered && + observations.childTaskReadable && observations.childIdentityPreserved && observations.replyIdentityPreserved)); + return { schemaVersion: 1, success, ...observations }; + } catch (error) { + const failure = error as { code?: unknown; killed?: boolean; stderr?: string; message?: string }; + observations.nativeExit = failure.killed ? "timeout" : typeof failure.code === "number" ? `exit_${failure.code}` : "fixture_error"; + const diagnostic = `${failure.stderr ?? ""} ${failure.message ?? ""}`; + for (const token of ["fixture_config_failed", "fixture_proxy_failed", "config.toml", "unexpected argument", "API key", "sandbox", "model", "authentication", "connection", "permission"]) + if (diagnostic.includes(token)) observations.nativeDiagnostic = token; + return { schemaVersion: 1, success: false, ...observations }; + } + finally { + for (const ws of wss.clients) ws.terminate(); + wss.close(); + await new Promise((resolve) => { if (proxy) proxy.close(() => resolve()); else resolve(); }); + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(() => resolve())); + await rm(temp, { recursive: true, force: true }); + } +} + +if (process.argv[1]?.endsWith("native-codex.ts")) { + const version = process.argv.includes("--v1") ? "v1" : "v2"; + const transport = process.argv.includes("--http") ? "http" : "websocket"; + if (process.argv.includes("--namespace-adapter")) { + const control = await runNativeCodex(version, transport); + console.log(JSON.stringify({ gate: "native_control", ...control })); + if (!control.success) { process.exitCode = 1; } else { + const report = await runNativeCodex(version, transport, true); + console.log(JSON.stringify({ gate: "namespace_adapter", ...report })); + process.exitCode = report.success ? 0 : 1; + } + } else { + const report = await runNativeCodex(version, transport); + console.log(JSON.stringify(report)); + process.exitCode = report.success ? 0 : 1; + } +} diff --git a/e2e/gates/native-process.ts b/e2e/gates/native-process.ts new file mode 100644 index 0000000..6085216 --- /dev/null +++ b/e2e/gates/native-process.ts @@ -0,0 +1,62 @@ +import { spawn } from "node:child_process"; + +export interface NativeProcessResult { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + readonly failure?: "timeout" | "output_limit" | "spawn_error"; + /** In-memory inspection only. Callers must emit allowlisted diagnostics. */ + readonly stdout: string; + readonly stderr: string; +} + +/** Own a dedicated process group so cancellation cannot target another native session. */ +export function nativeProcess(command: string, args: string[], options: { + cwd: string; env: NodeJS.ProcessEnv; timeoutMs?: number; maxBytes?: number; +}): Promise { + return new Promise((resolve) => { + const group = process.platform !== "win32"; + const child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: group, stdio: "pipe" }); + const stdout: Buffer[] = [], stderr: Buffer[] = []; + let bytes = 0; + let failure: NativeProcessResult["failure"]; + let force: ReturnType | undefined; + const kill = (signal: NodeJS.Signals) => { + try { if (group && child.pid) process.kill(-child.pid, signal); else child.kill(signal); } + catch { /* Already exited. Never search for or kill other processes. */ } + }; + const stop = (reason: NonNullable) => { + if (failure) return; + failure = reason; + kill("SIGTERM"); + force = setTimeout(() => kill("SIGKILL"), 500); + }; + const timer = setTimeout(() => stop("timeout"), options.timeoutMs ?? 45000); + const collect = (parts: Buffer[], chunk: Buffer) => { + bytes += chunk.length; + if (bytes > (options.maxBytes ?? 2 * 1024 * 1024)) { stop("output_limit"); return; } + parts.push(chunk); + }; + child.stdout.on("data", (chunk: Buffer) => collect(stdout, chunk)); + child.stderr.on("data", (chunk: Buffer) => collect(stderr, chunk)); + child.stdin.on("error", () => undefined); + // Native CLIs may wait for stdin EOF even when the prompt is an argument. + child.stdin.end(); + child.once("error", () => { failure = "spawn_error"; }); + child.once("close", (code, signal) => { + clearTimeout(timer); + if (force) clearTimeout(force); + // Clear any descendants that outlived the CLI wrapper, only in our group. + if (group && child.pid) kill("SIGKILL"); + resolve({ code, signal, ...(failure ? { failure } : {}), + stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }); + }); + }); +} + +export function isolatedNativeEnv(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/^(CODEX_|OPENAI_|ANTHROPIC_|CLAUDE_|SUBSWITCH_|NODE_OPTIONS$)/.test(key)) delete env[key]; + } + return { ...env, ...overrides }; +} diff --git a/e2e/gates/native-production.ts b/e2e/gates/native-production.ts new file mode 100644 index 0000000..19e663f --- /dev/null +++ b/e2e/gates/native-production.ts @@ -0,0 +1,110 @@ +/** Live acceptance of the production gateway with native subscription auth and real model discovery. */ +import http from "node:http"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { loadConfig } from "../../src/config.js"; +import { buildDeps, createProxyServer, listenServer } from "../../src/server.js"; +import { probeHeaders } from "./credentials.js"; +import { nativeProcess, isolatedNativeEnv } from "./native-process.js"; +import { object } from "./probe.js"; +import { createRawHttpForwarder, type RawHttpForwarder } from "../../src/raw-http-passthrough.js"; + +export async function runNativeProduction(model = "sonnet", followup = true, httpOnly = false, parentModel = "gpt-6-astra") { + const claude = await probeHeaders({ provider: "claude", auth: "subscription" }); + const openai = await probeHeaders({ provider: "openai", auth: "subscription" }); + const temp = await mkdtemp(join(tmpdir(), "subswitch-production-native-")); + const work = join(temp, "work"), codexDir = join(temp, "codex"); + const marker = `production-${randomUUID()}`, nextMarker = `followup-${randomUUID()}`; + const counts = { claudeResponses: 0, openaiResponses: 0, toolCalls: 0, toolResults: 0, modelRequests: 0, cachedPromptObserved: false, errors: [] as string[], + nativeCredentialMatches: false, nativeAccountHeaderMatches: false }; + let server: http.Server | undefined; + let relay: http.Server | undefined, forward: RawHttpForwarder | undefined; + try { + await mkdir(work, { mode: 0o700 }); await mkdir(codexDir, { mode: 0o700 }); + await writeFile(join(work, "check.txt"), marker, { mode: 0o600 }); + await writeFile(join(work, "followup.txt"), nextMarker, { mode: 0o600 }); + const authFile = join(temp, "claude.json"); + await writeFile(authFile, JSON.stringify({ claudeAiOauth: { accessToken: claude["authorization"]!.slice(7), expiresAt: Date.now() + 3600000 } }), { mode: 0o600 }); + const realAuth = object(JSON.parse(await readFile(join(process.env["CODEX_HOME"] ?? join(homedir(), ".codex"), "auth.json"), "utf8"))); + const tokens = object(realAuth?.["tokens"]); if (!tokens) throw new Error("auth_unavailable"); + const codexAuthFile = join(codexDir, "auth.json"); + await writeFile(codexAuthFile, JSON.stringify({ auth_mode: "chatgpt", tokens: { ...tokens, refresh_token: "" } }), { mode: 0o600 }); + let subscriptionBaseUrl: string | undefined; + if (httpOnly) { + forward = createRawHttpForwarder({ baseUrl: "https://chatgpt.com/backend-api/codex", connectTimeoutMs: 10000, maxUpstreamSockets: 8, + logger: { log() {} }, errorBody: () => '{"error":{"message":"HTTP control failed"}}', logPath: () => "/control", + events: { timeout: "openai_upstream_timeout", error: "openai_upstream_error" } }); + relay = http.createServer((req, res) => forward!(req, res)); + relay.on("upgrade", (_req, socket) => socket.end("HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")); + if (!(await listenServer(relay, 0, "127.0.0.1")).ok) throw new Error("http_control_failed"); + const address = relay.address(); if (!address || typeof address === "string") throw new Error("http_control_failed"); + subscriptionBaseUrl = `http://127.0.0.1:${address.port}`; + } + const config = loadConfig({ configPath: join(temp, "inline.json"), env: {}, readFile: () => JSON.stringify({ + codexIngress: { enabled: true, ...(subscriptionBaseUrl ? { subscriptionBaseUrl } : {}), claude: { enabled: true, authFile } }, + providers: { codex: { authFile: codexAuthFile, oauthTokenUrl: "http://127.0.0.1:9/disabled-refresh" } }, + }) }); + if (!config.ok) throw new Error("config_failed"); + const deps = buildDeps(config.value.config, { log(_level, event, fields) { + if (event === "claude_request_complete") counts.claudeResponses++; + if (event === "claude_request_complete" && (fields?.cachedTokens ?? 0) > 0) counts.cachedPromptObserved = true; + if (event === "codex_response_complete") counts.openaiResponses++; + if (event === "claude_tool_call") counts.toolCalls++; + if (event === "claude_tool_result") counts.toolResults++; + if (["claude_upstream_error", "claude_request_failed"].includes(event)) counts.errors.push(fields?.errorCode ?? event); + if (event === "openai_websocket_rejected" && !(httpOnly && fields?.status === 426)) counts.errors.push(`openai_websocket_${fields?.status}`); + } }); + if (!deps.ok) throw new Error("deps_failed"); + server = createProxyServer(deps.value); + server.prependListener("request", req => { if (req.url?.includes("/models")) counts.modelRequests++; }); + server.prependListener("upgrade", req => { + counts.nativeCredentialMatches = req.headers.authorization === openai["authorization"]; + counts.nativeAccountHeaderMatches = req.headers["chatgpt-account-id"] === openai["chatgpt-account-id"]; + }); + if (!(await listenServer(server, 0, "127.0.0.1")).ok) throw new Error("listen_failed"); + const address = server.address(); if (!address || typeof address === "string") throw new Error("listen_failed"); + const base = `http://127.0.0.1:${address.port}/codex/backend-api/codex`; + const env = isolatedNativeEnv({ CODEX_HOME: codexDir }); + const version = await nativeProcess("codex", ["--version"], { cwd: work, env, timeoutMs: 10000 }); + const clientVersion = version.stdout.match(/codex-cli (\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?)/)?.[1]; + if (version.code !== 0 || !clientVersion) throw new Error("version_unavailable"); + const catalogResponse = await fetch(`${base}/models?client_version=${clientVersion}`, { + headers: { ...openai, accept: "application/json" }, signal: AbortSignal.timeout(30000), + }); + if (!catalogResponse.ok) return { success: false, stage: "discovery", status: catalogResponse.status, ...counts }; + const catalog = object(await catalogResponse.json()); + if (!Array.isArray(catalog?.["models"]) || !catalog["models"].some(value => object(value)?.["slug"] === model)) throw new Error("model_not_discovered"); + const parentVersion = object(catalog["models"].find(value => object(value)?.["slug"] === parentModel))?.["multi_agent_version"]; + // Leave the native cache absent: the client must discover Claude models through the proxy itself. + await writeFile(join(codexDir, "worker.toml"), `model = ${JSON.stringify(model)}\ndeveloper_instructions = "Read the requested file using native tools, then return its exact content."\n`, { mode: 0o600 }); + await writeFile(join(codexDir, "config.toml"), [ + `model = ${JSON.stringify(parentModel)}`, `openai_base_url = ${JSON.stringify(base)}`, 'approval_policy = "never"', 'sandbox_mode = "read-only"', + '[agents.claude_worker]', 'description = "Read the isolated fixture with Claude"', 'config_file = "worker.toml"', + ].join("\n"), { mode: 0o600 }); + const spawning = parentVersion === "v1" ? `Spawn exactly one native child using the configured claude_worker agent role (model ${model}).` : + `Spawn exactly one native child with task_name claude_worker, model ${model}, fork_turns none.`; + const result = await nativeProcess("codex", ["exec", "--ephemeral", "--ignore-rules", "--skip-git-repo-check", "--json", + `${spawning} Ask it to read check.txt with the native tools and return the exact content. Wait for it. ${followup ? "Then send a follow-up to that same child asking it to read followup.txt using the tool, wait, and return both exact values." : "Return the exact value it reported."} Do not read the files yourself.`], + { cwd: work, env, timeoutMs: 120000 }); + return { schemaVersion: 1, productionGateway: true, nativeAuthentication: "subscription", realDiscovery: true, model, followup, httpOnly, + parentModel, parentVersion: typeof parentVersion === "string" ? parentVersion : "default", + firstValueReturned: result.stdout.includes(marker), followupValueReturned: result.stdout.includes(nextMarker), + nativeDiagnostics: ["websocket", "schema", "unauthorized", "forbidden", "not found", "unknown variant", "not available", "missing field"] + .filter(value => (result.stderr + result.stdout).toLowerCase().includes(value)), + success: result.code === 0 && !result.failure && result.stdout.includes(marker) && (!followup || result.stdout.includes(nextMarker)) && + counts.modelRequests >= 2 && counts.claudeResponses >= (followup ? 4 : 2) && counts.openaiResponses >= 2 && counts.toolCalls >= (followup ? 2 : 1) && counts.toolResults >= (followup ? 2 : 1) && !counts.errors.length, + nativeExit: result.failure ?? `exit_${result.code}`, ...counts }; + } finally { + server?.closeAllConnections(); await new Promise(resolve => { if (server) server.close(() => resolve()); else resolve(); }); + forward?.close(); relay?.closeAllConnections(); await new Promise(resolve => { if (relay) relay.close(() => resolve()); else resolve(); }); + await rm(temp, { recursive: true, force: true }); + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { const parentFlag = process.argv.indexOf("--parent"); + const report = await runNativeProduction(process.argv[2] ?? "sonnet", !process.argv.includes("--no-followup"), process.argv.includes("--http"), parentFlag < 0 ? undefined : process.argv[parentFlag + 1]); console.log(JSON.stringify(report)); process.exitCode = report.success ? 0 : 1; } + catch { console.log(JSON.stringify({ success: false, code: "production_native_unavailable" })); process.exitCode = 2; } +} diff --git a/e2e/gates/native-reverse.ts b/e2e/gates/native-reverse.ts new file mode 100644 index 0000000..b689e85 --- /dev/null +++ b/e2e/gates/native-reverse.ts @@ -0,0 +1,250 @@ +/** Source-only reverse experiment. Native clients/configuration are isolated; no production activation. */ +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { gunzipSync, brotliDecompressSync, inflateSync, zstdDecompressSync } from "node:zlib"; +import { WebSocketServer } from "ws"; +import { loadConfig } from "../../src/config.js"; +import { buildDeps, createProxyServer, listenServer } from "../../src/server.js"; +import { createSseParser } from "../../src/codex-response.js"; +import { nativeProcess, isolatedNativeEnv } from "./native-process.js"; +import { withNativeClaudeCapture } from "./claude-system-control.js"; +import { probeHeaders } from "./credentials.js"; +import { namespaceRequest, namespaceEvent } from "./namespace-adapter.js"; +import { object } from "./probe.js"; +import { reverseRequest, reverseResponse, reverseEvents, ReverseContractError, type Item } from "./reverse-adapter.js"; +import { ReverseState } from "./reverse-state.js"; + +class ExperimentError extends Error { constructor(readonly code: string) { super(code); } } + +const PARENT = "gpt-6-astra", CHILD = "claude-sonnet-5", MAX_BYTES = 4 * 1024 * 1024; +const TASK = "Read check.txt using the native code tool functions.exec and tools.exec_command. Return the file's exact content. You must execute the tool before answering."; +const textOutput = (text: string): Item => ({ type: "message", id: `msg_${randomUUID()}`, role: "assistant", phase: "final_answer", status: "completed", + content: [{ type: "output_text", text, annotations: [] }] }); +const functionOutput = (name: string, args: Item): Item => ({ type: "function_call", id: `fc_${randomUUID()}`, call_id: `call_${randomUUID()}`, + namespace: "collaboration", name, arguments: JSON.stringify(args), encrypted_function_args: [], status: "completed" }); + +export async function runNativeReverse(options: { liveParent?: boolean; http?: boolean; followup?: boolean; nativeSubscription?: boolean } = {}) { + return withNativeClaudeCapture(async (capture, claudeHeaders) => { + const nativePreamble = (capture.body["system"] as unknown[]).map(object).filter((block): block is Item => + !!block && typeof block["text"] === "string" && /^(You are Claude Code,|You are a Claude agent,)/.test(block["text"])); + if (nativePreamble.length !== 1) throw new Error("missing_native_preamble"); + const parentHeaders = options.liveParent ? await probeHeaders({ provider: "openai", auth: "subscription" }) : undefined; + const temp = await mkdtemp(join(tmpdir(), "subswitch-native-reverse-")); + const work = join(temp, "work"), codexDir = join(temp, "codex"); + const marker = `reverse-read-${randomUUID()}`; + const followupMarker = `reverse-followup-${randomUUID()}`; + const followupTask = TASK.replace("check.txt", "followup.txt"); + const stats = { parentRequests: 0, childRequests: 0, warmups: 0, websocketConnections: 0, httpRequests: 0, + toolRequested: false, nativeToolResult: false, childAnswered: false, parentReceived: false, + followupToolResult: false, followupAnswered: false, followupReceived: false, + upstreamStatuses: [] as { provider: string; status: number }[], errors: [] as string[], nativeExit: "not_started" }; + const abort = new AbortController(); + const history = new Map(); + const state = new ReverseState(); + let spawned = false; + let followedUp = false; + const fetchBody = async (provider: string, url: string, headers: Record, body: Item): Promise => { + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), redirect: "error", + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(30000)]) }); + stats.upstreamStatuses.push({ provider, status: response.status }); + const reader = response.body?.getReader(); + if (!reader) throw new ExperimentError("missing_upstream_body"); + const chunks: Buffer[] = []; let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); if (done) break; + bytes += value.byteLength; if (bytes > MAX_BYTES) throw new ExperimentError("upstream_body_limit"); chunks.push(Buffer.from(value)); + } + } finally { await reader.cancel(); } + if (!response.ok) { + let detail = ""; + try { + const message = object(object(JSON.parse(Buffer.concat(chunks).toString("utf8")))?.["error"])?.["message"]; + if (typeof message === "string") detail = ["thinking", "signature", "tool_use", "tool_result", "system", "extra usage", "max_tokens", "beta"] + .filter(word => message.toLowerCase().includes(word)).map(word => word.replace(" ", "_")).join("_"); + } catch { /* Closed diagnostic categories only. */ } + throw new ExperimentError(`${provider}_http_${response.status}${detail ? `_${detail}` : ""}`); + } + return Buffer.concat(chunks); + }; + const exchange = async (body: Item): Promise => { + if (stats.parentRequests + stats.childRequests + stats.warmups >= 24) throw new ExperimentError("experiment_request_limit"); + const previous = typeof body["previous_response_id"] === "string" ? history.get(body["previous_response_id"]) : undefined; + if (body["previous_response_id"] !== undefined && !previous) throw new ExperimentError("missing_continuation_state"); + const model = typeof body["model"] === "string" ? body["model"] : previous?.model; + if (model !== CHILD && model !== PARENT) throw new ExperimentError("unexpected_model"); + const input = [...previous?.input ?? [], ...(Array.isArray(body["input"]) ? body["input"] as Item[] : [])]; + const tools = body["tools"] ?? previous?.tools; + let id = `resp_reverse_${randomUUID()}`; + let output: Item[]; + let frames: Item[]; + if (body["generate"] === false) { + stats.warmups++; output = []; frames = reverseEvents(id, model, []); + } else if (model === CHILD) { + stats.childRequests++; + stats.nativeToolResult ||= input.some(entry => ["function_call_output", "custom_tool_call_output"].includes(String(entry["type"])) && JSON.stringify(entry["output"]).includes(marker)); + stats.followupToolResult ||= input.some(entry => ["function_call_output", "custom_tool_call_output"].includes(String(entry["type"])) && JSON.stringify(entry["output"]).includes(followupMarker)); + const full = { ...body, model, input, ...(tools === undefined ? {} : { tools }) }; + const request = reverseRequest(full, nativePreamble, state); + const raw = await fetchBody("claude", "https://api.anthropic.com/v1/messages", { + ...claudeHeaders, "anthropic-beta": "claude-code-20250219,oauth-2025-04-20", + }, request.body); + const response = object(JSON.parse(raw.toString("utf8"))); + if (!response) throw new ExperimentError("invalid_claude_response"); + output = reverseResponse(response, request, state); + stats.toolRequested ||= output.some(entry => ["function_call", "custom_tool_call"].includes(String(entry["type"])) && entry["namespace"] === "functions"); + stats.childAnswered ||= output.some(entry => entry["type"] === "message" && JSON.stringify(entry["content"]).includes(marker)); + stats.followupAnswered ||= output.some(entry => entry["type"] === "message" && JSON.stringify(entry["content"]).includes(followupMarker)); + frames = reverseEvents(id, model, output, object(response["usage"]) ?? {}); + } else { + stats.parentRequests++; + stats.parentReceived ||= input.some(entry => entry["type"] === "agent_message" && JSON.stringify(entry["content"]).includes(marker)); + stats.followupReceived ||= input.some(entry => entry["type"] === "agent_message" && JSON.stringify(entry["content"]).includes(followupMarker)); + if (parentHeaders) { + const request = namespaceRequest({ ...body, model, input, ...(tools === undefined ? {} : { tools }), store: false, stream: true }); + delete request["previous_response_id"]; delete request["type"]; delete request["stream_id"]; + const raw = await fetchBody("openai", "https://chatgpt.com/backend-api/codex/responses", parentHeaders, request); + const parser = createSseParser(MAX_BYTES); parser.end(raw); + frames = []; output = []; let completed = false; + for await (const event of parser) { + if (!event.data || event.data === "[DONE]") continue; + const frame = namespaceEvent(JSON.parse(event.data)); + if (["error", "response.failed", "response.incomplete"].includes(String(frame["type"]))) throw new ExperimentError("openai_terminal_failure"); + frames.push(frame); + if (frame["type"] === "response.output_item.done") output.push(object(frame["item"]) ?? {}); + if (frame["type"] === "response.completed") { + const response = object(frame["response"]); + if (response?.["status"] !== "completed" || typeof response["id"] !== "string") throw new ExperimentError("invalid_openai_terminal"); + id = response["id"]; completed = true; + if (!output.length && Array.isArray(response["output"])) output = response["output"] as Item[]; + } + } + if (!completed) throw new ExperimentError("missing_openai_terminal"); + } else { + if (stats.errors.length) output = [textOutput("The reverse experiment failed.")]; + else if (!spawned) { spawned = true; output = [functionOutput("spawn_agent", { task_name: "sonnet", model: CHILD, fork_turns: "none", message: TASK })]; } + else if (stats.parentReceived && options.followup && !followedUp) { + followedUp = true; output = [functionOutput("followup_task", { target: "sonnet", message: followupTask })]; + } else if (stats.parentReceived && (!options.followup || stats.followupReceived)) output = [textOutput(options.followup ? `${marker}\n${followupMarker}` : marker)]; + else output = [functionOutput("wait_agent", { timeout_ms: 10000 })]; + frames = reverseEvents(id, model, output); + } + } + history.set(id, { model, input: [...input, ...output], ...(tools === undefined ? {} : { tools }) }); + return frames; + }; + const errorFrame = (error: unknown) => { + const code = (error instanceof ExperimentError || error instanceof ReverseContractError) ? error.code : "experiment_error"; + stats.errors.push(code); + process.stderr.write(JSON.stringify({ stage: "reverse_contract", code }) + "\n"); + return { type: "error", code, message: "The isolated reverse contract failed." }; + }; + const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_BYTES }); + let models: Item[] = []; + const upstream = http.createServer(async (req, res) => { + try { + if (req.url?.includes("/models")) { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ models })); return; } + if (req.method !== "POST" || !req.url?.includes("/responses")) { res.writeHead(404); res.end(); return; } + const chunks: Buffer[] = []; let bytes = 0; + for await (const chunk of req) { bytes += chunk.length; if (bytes > MAX_BYTES) throw new ExperimentError("incoming_body_limit"); chunks.push(chunk); } + let raw: Buffer = Buffer.concat(chunks); + const decoders: Record Buffer> = { + gzip: gunzipSync, br: brotliDecompressSync, deflate: inflateSync, zstd: zstdDecompressSync, + }; + const encoding = req.headers["content-encoding"]; + if (typeof encoding === "string" && encoding !== "identity") { + const decode = decoders[encoding]; if (!decode) throw new ExperimentError("unsupported_encoding"); + raw = decode(raw, { maxOutputLength: MAX_BYTES }); + } + stats.httpRequests++; + const frames = await exchange(JSON.parse(raw.toString("utf8"))); + res.setHeader("content-type", "text/event-stream"); res.end(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("")); + } catch (error) { res.writeHead(502, { "content-type": "text/event-stream" }); res.end(`data: ${JSON.stringify(errorFrame(error))}\n\n`); } + }); + upstream.on("upgrade", (req, socket, head) => { + if (options.http) { socket.end("HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; } + wss.handleUpgrade(req, socket, head, ws => { + stats.websocketConnections++; ws.on("error", () => undefined); + let queue = Promise.resolve(); + ws.on("message", data => { + queue = queue.then(async () => { + try { + const body = JSON.parse(data.toString()); + for (const frame of await exchange(body)) if (ws.readyState === 1) + ws.send(JSON.stringify({ ...frame, ...(typeof body.stream_id === "string" ? { stream_id: body.stream_id } : {}) })); + } catch (error) { if (ws.readyState === 1) ws.send(JSON.stringify(errorFrame(error))); } + }); + }); + }); + }); + let proxy: http.Server | undefined; + try { + await mkdir(work, { mode: 0o700 }); await mkdir(codexDir, { mode: 0o700 }); + await writeFile(join(work, "check.txt"), marker, { mode: 0o600 }); + if (options.followup) await writeFile(join(work, "followup.txt"), followupMarker, { mode: 0o600 }); + const env = isolatedNativeEnv({ CODEX_HOME: codexDir, ...(options.nativeSubscription ? {} : { OPENAI_API_KEY: "fabricated-local-client-key" }) }); + const version = await nativeProcess("codex", ["--version"], { cwd: work, env, timeoutMs: 10000 }); + const clientVersion = version.stdout.match(/codex-cli (\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?)/)?.[1]; + if (version.failure || version.code !== 0 || !clientVersion) throw new ExperimentError("codex_version_unavailable"); + const fixture = JSON.parse(await readFile(new URL("../../test/fixtures/native/codex-0.153.3-model.json", import.meta.url), "utf8")); + models = [PARENT, CHILD].map(slug => ({ ...fixture, slug, display_name: slug, multi_agent_version: "v2", + model_messages: null, base_instructions: "You are an agent running inside native Codex. Follow the supplied tool definitions and user task.", supported_in_api: true })); + if (!(await listenServer(upstream, 0, "127.0.0.1")).ok) throw new ExperimentError("listen_failed"); + const address = upstream.address(); if (!address || typeof address === "string") throw new ExperimentError("listen_failed"); + const base = `http://127.0.0.1:${address.port}`; + const config = loadConfig({ env: {}, configPath: join(temp, "inline.json"), readFile: () => JSON.stringify({ + anthropic: { baseUrl: base }, providers: { codex: { authFile: join(temp, "unused-auth.json") } }, + codexIngress: { enabled: true, subscriptionBaseUrl: `${base}/backend-api/codex`, apiBaseUrl: `${base}/v1` }, + }) }); + if (!config.ok) throw new ExperimentError("config_failed"); + const deps = buildDeps(config.value.config, { log: () => undefined }); + if (!deps.ok) throw new ExperimentError("proxy_failed"); + proxy = createProxyServer(deps.value); + if (!(await listenServer(proxy, 0, "127.0.0.1")).ok) throw new ExperimentError("proxy_listen_failed"); + const proxyAddress = proxy.address(); if (!proxyAddress || typeof proxyAddress === "string") throw new ExperimentError("proxy_listen_failed"); + let localAuth: Item = { OPENAI_API_KEY: "fabricated-local-client-key" }; + if (options.nativeSubscription) { + // A restricted, temporary access-only copy. The live shared refresh token is never copied. + const source = object(JSON.parse(await readFile(join(process.env["CODEX_HOME"] ?? join(homedir(), ".codex"), "auth.json"), "utf8"))); + const tokens = object(source?.["tokens"]); + if (!tokens || typeof tokens["access_token"] !== "string") throw new ExperimentError("native_subscription_missing"); + localAuth = { auth_mode: "chatgpt", tokens: { ...tokens, refresh_token: "" } }; + } + await writeFile(join(codexDir, "auth.json"), JSON.stringify(localAuth), { mode: 0o600 }); + await writeFile(join(codexDir, "models_cache.json"), JSON.stringify({ fetched_at: new Date().toISOString(), etag: "reverse-fixture", client_version: clientVersion, models }), { mode: 0o600 }); + await writeFile(join(codexDir, "sonnet.toml"), `model = "${CHILD}"\ndeveloper_instructions = "${TASK}"\n`, { mode: 0o600 }); + await writeFile(join(codexDir, "config.toml"), [ + `model = "${PARENT}"`, `openai_base_url = "http://127.0.0.1:${proxyAddress.port}/codex/${options.nativeSubscription ? "backend-api/codex" : "v1"}"`, + 'approval_policy = "never"', 'sandbox_mode = "read-only"', '[agents.sonnet]', + 'description = "Read the isolated file using Claude"', 'config_file = "sonnet.toml"', + ].join("\n"), { mode: 0o600 }); + const native = await nativeProcess("codex", ["exec", "--skip-git-repo-check", "--ephemeral", "--ignore-rules", "--json", + `Spawn exactly one sonnet child with model ${CHILD}, task_name sonnet, fork_turns none. Ask it: ${TASK} Wait for its result. ${options.followup ? `Then send followup_task to the same child asking: ${followupTask} Wait for its second result and return both exact values.` : "Return the exact value it read."} Do not read the files yourself.`], + { cwd: work, env, timeoutMs: 90000 }); + stats.nativeExit = native.failure ?? `exit_${native.code}`; + return { schemaVersion: 1, liveParent: !!options.liveParent, nativePreambleUsed: true, clientVersion, + nativeAuthentication: options.nativeSubscription ? "subscription" : "fabricated_local_api", + nativeDiagnostics: ["error decoding", "missing field", "failed to parse", "unsupported call", "stream disconnected", "invalid response"] + .filter(word => native.stderr.toLowerCase().includes(word)), + transport: options.http ? "http" : "websocket", followup: !!options.followup, success: native.code === 0 && !native.failure && + stats.toolRequested && stats.nativeToolResult && stats.childAnswered && stats.parentReceived && native.stdout.includes(marker) && !stats.errors.length && + (!options.followup || (stats.followupToolResult && stats.followupAnswered && stats.followupReceived && native.stdout.includes(followupMarker))), ...stats }; + } catch (error) { errorFrame(error); return { schemaVersion: 1, success: false, liveParent: !!options.liveParent, ...stats }; } + finally { + abort.abort(); for (const ws of wss.clients) ws.terminate(); wss.close(); + if (proxy) { proxy.closeAllConnections(); await new Promise(resolve => proxy!.close(() => resolve())); } + upstream.closeAllConnections(); await new Promise(resolve => upstream.close(() => resolve())); + history.clear(); await rm(temp, { recursive: true, force: true }); + } + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { const report = await runNativeReverse({ liveParent: process.argv.includes("--live-parent"), http: process.argv.includes("--http"), followup: process.argv.includes("--followup"), nativeSubscription: process.argv.includes("--native-subscription") }); + console.log(JSON.stringify(report)); process.exitCode = report.success ? 0 : 1; + } catch { console.log(JSON.stringify({ schemaVersion: 1, success: false, code: "native_reverse_unavailable" })); process.exitCode = 2; } +} diff --git a/e2e/gates/parity-scope.md b/e2e/gates/parity-scope.md new file mode 100644 index 0000000..5f1f935 --- /dev/null +++ b/e2e/gates/parity-scope.md @@ -0,0 +1,22 @@ +# Bidirectional parity scope — 2026-09-07 + +The latest user instruction replaces the broader original implementation plan: +ship the reverse counterpart of existing forward behavior, and track shared gaps +as issues rather than introducing reverse-only capabilities. Current live results are in +[production parity acceptance](production-parity.md). Shared follow-ups are +[#45](https://github.com/dean0x/subswitch/issues/45), [#46](https://github.com/dean0x/subswitch/issues/46), +[#47](https://github.com/dean0x/subswitch/issues/47), and [#48](https://github.com/dean0x/subswitch/issues/48). + +| Area | Existing Claude Code → Codex | This change: Codex → Claude | +| --- | --- | --- | +| Routing | Canonical registry, aliases, model-based selection; unmatched Anthropic traffic passes through | Claude registry/aliases; unmatched OpenAI traffic passes through; native collaboration namespace compatibility | +| Native ownership | Claude Code owns agents/tools/permissions | Codex owns agents/tools/permissions; native HTTP and WebSockets required | +| Authentication | Existing subscription store, refresh, one 401 refresh retry; no API billing fallback | Existing Claude subscription store (Keychain/file), refresh, bounded 401 retry; documented native identity preamble | +| Translation | Text/instructions, function tools/results, effort, SSE/non-streaming and errors | Corresponding Responses/Responses-lite forms, plus reversible native namespace/freeform encoding | +| State | Bounded process-local reasoning cache | Bounded process-local continuation state and authenticated thinking replay; missing state fails explicitly | +| Setup | Init, preview/non-interactive modes, preserve unrelated settings; no undo | Equivalent Codex init and preview; global native endpoint configuration requires a user-level SubSwitch fallback | +| Diagnostics | Doctor, model listing, startup/health, content-free logs | Direction-aware equivalents and native model discovery | +| Not in this change | Durable restart/resume, translated compaction, setup undo, API-key auth for translation, broad content/hosted-tool extensions | Track these for both directions; preserve raw same-provider traffic where no translation is needed | + +No native binaries are patched or downgraded. No production setup on this machine +is changed by development tests; native runs use separate directories and ports. diff --git a/e2e/gates/probe.ts b/e2e/gates/probe.ts new file mode 100644 index 0000000..cd0a978 --- /dev/null +++ b/e2e/gates/probe.ts @@ -0,0 +1,297 @@ +import { createSseParser } from "../../src/codex-response.js"; +import { namespaceRequest } from "./namespace-adapter.js"; +import { claudeToolRequest, openaiSchemaRequest, openaiArgumentsRequest, openaiMarkerRequest, openaiNativeArgumentsRequest, openaiSchemaFieldRequest, TASK } from "./contracts.js"; + +export type GateProvider = "openai" | "claude"; +export type GateAuth = "subscription" | "api"; +export type GateStatus = "pass" | "blocked" | "fail"; +type ObjectValue = Record; + +export const object = (value: unknown): ObjectValue | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as ObjectValue : undefined; + +export interface GateResult { + readonly gate: string; + readonly status: GateStatus; + /** Closed local codes, never an upstream message, prompt, or credential. */ + readonly code: string; + readonly httpStatus?: number; + readonly retryAfter?: string; +} + +export interface GateReport { + readonly schemaVersion: 1; + readonly provider: GateProvider; + readonly auth: GateAuth; + readonly results: readonly GateResult[]; +} + +export interface ProbeOptions { + readonly provider: GateProvider; + readonly auth: GateAuth; + readonly model: string; + readonly headers: Readonly>; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; + readonly contract?: "native-history" | "native-arguments" | "schema-fields" | "namespace-control"; +} + +const MAX_RESPONSE_BYTES = 1024 * 1024; +const ERROR_TYPES = new Set([ + "authentication_error", "permission_error", "rate_limit_error", "invalid_request_error", + "not_found_error", "overloaded_error", "api_error", "insufficient_quota", +]); + +/** Retry guidance is retained only if it is an RFC delay or HTTP date. */ +const retryAfter = (headers: Headers): { retryAfter?: string } => { + const value = headers.get("retry-after"); + if (value !== null && (/^\d{1,12}$/.test(value) || + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(value))) { + return { retryAfter: value }; + } + return {}; +}; + +class ProbeFailure extends Error { + constructor(readonly code: string) { super(code); } +} + +async function boundedBody(response: Response, signal: AbortSignal): Promise { + if (!response.body) throw new ProbeFailure("missing_body"); + const reader = response.body.getReader(); + const parts: Buffer[] = []; + let size = 0; + const cancel = () => { void reader.cancel().catch(() => undefined); }; + signal.addEventListener("abort", cancel, { once: true }); + try { + while (true) { + signal.throwIfAborted(); + const { done, value } = await reader.read(); + signal.throwIfAborted(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) throw new ProbeFailure("response_too_large"); + parts.push(Buffer.from(value)); + } + return Buffer.concat(parts); + } finally { + signal.removeEventListener("abort", cancel); + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + +async function responseOutput(raw: Buffer): Promise { + // Require a real terminal event. [DONE], output_item.done, and EOF aren't completion. + const parser = createSseParser(MAX_RESPONSE_BYTES); + parser.end(raw); + let completed: ObjectValue | undefined; + const items = new Map(); + for await (const frame of parser) { + if (frame.data === "[DONE]") continue; + const event = object(JSON.parse(frame.data)); + if (!event) throw new ProbeFailure("invalid_event"); + if (event["type"] === "response.output_item.done") { + if (completed) throw new ProbeFailure("output_after_terminal"); + const index = event["output_index"]; + const item = object(event["item"]); + if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0 || !item || items.has(index)) + throw new ProbeFailure("invalid_output_item"); + items.set(index, item); + } + if (["error", "response.failed", "response.incomplete"].includes(String(event["type"]))) { + throw new ProbeFailure("upstream_terminal_failure"); + } + if (event["type"] === "response.completed") { + if (completed) throw new ProbeFailure("duplicate_terminal"); + completed = object(event["response"]); + if (!completed || completed["status"] !== "completed" || !Array.isArray(completed["output"])) { + throw new ProbeFailure("invalid_terminal"); + } + } + } + if (!completed) throw new ProbeFailure("missing_terminal"); + // Responses-lite can leave terminal output empty; native Codex consumes the + // preceding output_item.done events. EOF alone still never establishes success. + if ((completed["output"] as unknown[]).length === 0 && items.size > 0) { + const output: ObjectValue[] = []; + for (let index = 0; index < items.size; index++) { + const item = items.get(index); + if (!item) throw new ProbeFailure("missing_output_item"); + output.push(item); + } + completed = { ...completed, output }; + } + return completed; +} + +interface CallResult { readonly result: GateResult; readonly body?: ObjectValue } + +async function call(options: ProbeOptions, gate: string, body: unknown): Promise { + const endpoint = options.provider === "claude" ? "https://api.anthropic.com/v1/messages" : + options.auth === "subscription" ? "https://chatgpt.com/backend-api/codex/responses" : + "https://api.openai.com/v1/responses"; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 30_000); + let httpStatus: number | undefined; + try { + const response = await (options.fetchImpl ?? fetch)(endpoint, { + method: "POST", headers: options.headers, body: JSON.stringify(body), + signal: controller.signal, redirect: "error", + }); + httpStatus = response.status; + const raw = await boundedBody(response, controller.signal); + if (!response.ok) { + let code = "upstream_http_error"; + try { + const upstream = object(object(JSON.parse(raw.toString("utf8")))?.["error"]); + const type = upstream?.["type"]; + if (typeof type === "string" && ERROR_TYPES.has(type)) code = type; + // A generic 429 does not identify exhausted subscription capacity. Only + // report a spend limit when Anthropic explicitly supplies that signal. + if (options.provider === "claude" && httpStatus === 429 && + object(upstream?.["details"])?.["error_code"] === "enforced_spend_limit_reached") { + code = "enforced_spend_limit_reached"; + } + for (const name of ["spawn_agent", "send_message", "followup_task"]) { + if (upstream?.["param"] === "tools" && upstream["message"] === + `Invalid Value: 'tools'. Function 'collaboration.${name}' is reserved for use by this model and must match the configured schema.`) { + code = `reserved_collaboration_schema_${name}`; + } + } + } catch { /* Never expose an HTML/error body or parser exception. */ } + return { result: { + gate, status: [401, 403, 429].includes(httpStatus) || httpStatus >= 500 ? "blocked" : "fail", + code, httpStatus, ...retryAfter(response.headers), + } }; + } + const parsed = options.provider === "openai" ? await responseOutput(raw) : object(JSON.parse(raw.toString("utf8"))); + if (!parsed) throw new ProbeFailure("invalid_response"); + return { result: { gate, status: "pass", code: "accepted", httpStatus }, body: parsed }; + } catch (error) { + return { result: { + gate, status: controller.signal.aborted || httpStatus === undefined ? "blocked" : "fail", + code: controller.signal.aborted ? "request_timeout" : error instanceof ProbeFailure ? error.code : + httpStatus === undefined ? "network_error" : "invalid_response", + ...(httpStatus === undefined ? {} : { httpStatus }), + } }; + } finally { clearTimeout(timer); } +} + +const failedContract = (result: GateResult, code: string): GateResult => ({ ...result, status: "fail", code }); + +async function probeClaude(options: ProbeOptions): Promise { + const request = claudeToolRequest(options.model); + const first = await call(options, "claude_tool_call", request); + if (!first.body) return [first.result]; + const content = first.body["content"]; + const calls = Array.isArray(content) ? content.map(object).filter((item) => item?.["type"] === "tool_use") : []; + const tool = calls[0]; + if (first.body["stop_reason"] !== "tool_use" || calls.length !== 1 || !tool || + typeof tool["id"] !== "string" || !tool["id"] || tool["name"] !== "echo" || + object(tool["input"])?.["text"] !== "gate-ok") { + return [failedContract(first.result, "unexpected_tool_call")]; + } + const next = await call(options, "claude_tool_continuation", { + ...request, tool_choice: { type: "none" }, + messages: [ + ...request.messages, + // Replay ALL blocks verbatim, including thinking/signature/redacted state. + { role: "assistant", content }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool["id"], content: "gate-ok" }] }, + ], + }); + const final = next.body; + const hasText = Array.isArray(final?.["content"]) && final["content"].some((item: unknown) => + object(item)?.["type"] === "text" && typeof object(item)?.["text"] === "string" && + (object(item)?.["text"] as string).includes("gate-ok")); + return [first.result, final && (final["stop_reason"] !== "end_turn" || !hasText) ? + failedContract(next.result, "invalid_continuation") : next.result]; +} + +async function probeOpenai(options: ProbeOptions): Promise { + const control = await call(options, "openai_native_schema_control", openaiSchemaRequest(options.model, false)); + if (!control.body) return [control.result]; + if (options.contract === "namespace-control") { + // A diagnostic control, not production namespace rewriting. This distinguishes + // general schema support from the backend's reserved collaboration contract. + const request = openaiSchemaRequest(options.model); + request.input[0]!.tools![0]!.name = "subswitch_collaboration"; + const schema = await call(options, "openai_ordinary_namespace_schema", request); + if (!schema.body) return [control.result, schema.result]; + request.input[1] = { type: "message", role: "user", content: [{ type: "input_text", text: + `Call subswitch_collaboration.spawn_agent once with task_name probe, model claude-sonnet-5, fork_turns none, and message exactly ${JSON.stringify(TASK)}.` }] }; + const generated = await call(options, "openai_ordinary_namespace_arguments", request); + if (!generated.body) return [control.result, schema.result, generated.result]; + const calls = (generated.body["output"] as unknown[]).map(object).filter((item) => item?.["type"] === "function_call"); + let plaintext = false; + try { + const tool = calls[0]; + const args = object(JSON.parse(String(tool?.["arguments"]))); + plaintext = calls.length === 1 && tool?.["namespace"] === "subswitch_collaboration" && tool["name"] === "spawn_agent" && + args?.["message"] === TASK && args["model"] === "claude-sonnet-5"; + } catch { /* Never relabel or decrypt opaque payloads. */ } + if (!plaintext) return [control.result, schema.result, failedContract(generated.result, "plaintext_arguments_not_observed")]; + const history = await call(options, "openai_ordinary_namespace_history", namespaceRequest(openaiMarkerRequest(options.model, false))); + return [control.result, schema.result, generated.result, history.result]; + } + if (options.contract === "schema-fields") { + const results = [control.result]; + for (const name of ["spawn_agent", "send_message", "followup_task"] as const) { + for (const mode of ["false", "omit"] as const) { + const variant = await call(options, `openai_schema_${name}_${mode}`, openaiSchemaFieldRequest(options.model, name, mode)); + results.push(variant.result); + // Independent schema comparisons are not inference retries or fallbacks. + // Stop the matrix on auth, transport, or availability failures. + if (variant.result.status === "blocked") return results; + } + } + return results; + } + if (options.contract === "native-history") { + const history = await call(options, "openai_native_plaintext_history", openaiMarkerRequest(options.model, false)); + return [control.result, history.result]; + } + if (options.contract === "native-arguments") { + const generated = await call(options, "openai_native_sonnet_arguments", openaiNativeArgumentsRequest(options.model)); + if (!generated.body) return [control.result, generated.result]; + const calls = (generated.body["output"] as unknown[]).map(object).filter((item) => item?.["type"] === "function_call"); + let plaintext = false; + try { + const tool = calls[0]; + const args = object(JSON.parse(String(tool?.["arguments"]))); + plaintext = calls.length === 1 && tool?.["namespace"] === "collaboration" && tool["name"] === "spawn_agent" && + args?.["message"] === TASK && args["model"] === "claude-sonnet-5"; + } catch { /* No ciphertext decoding or relabeling. */ } + return [control.result, plaintext ? generated.result : failedContract(generated.result, "native_plaintext_arguments_not_observed")]; + } + const first = await call(options, "openai_plaintext_schema", openaiSchemaRequest(options.model)); + if (!first.body) return [control.result, first.result]; + const argumentsResult = await call(options, "openai_plaintext_arguments", openaiArgumentsRequest(options.model)); + const prefix = [control.result, first.result]; + if (!argumentsResult.body) return [...prefix, argumentsResult.result]; + const calls = (argumentsResult.body["output"] as unknown[]).map(object).filter((item) => item?.["type"] === "function_call"); + let valid = false; + try { + const tool = calls[0]; + const args = object(JSON.parse(String(tool?.["arguments"]))); + valid = calls.length === 1 && tool?.["namespace"] === "collaboration" && tool["name"] === "spawn_agent" && + args?.["task_name"] === "probe" && args["message"] === TASK; + } catch { /* Ciphertext and invalid/truncated JSON never become executable calls. */ } + if (!valid) return [...prefix, failedContract(argumentsResult.result, "plaintext_arguments_not_observed")]; + // This is deliberately a fabricated history, not ciphertext relabeled as plaintext. + const second = await call(options, "openai_plaintext_markers", openaiMarkerRequest(options.model)); + return [...prefix, argumentsResult.result, second.result]; +} + +/** No retries, mode fallback, tool execution, credential writes, or client configuration changes. */ +export async function runProbe(options: ProbeOptions): Promise { + return { + schemaVersion: 1, provider: options.provider, auth: options.auth, + results: await (options.provider === "claude" ? probeClaude(options) : probeOpenai(options)), + }; +} + +export const reportExitCode = (report: GateReport): number => + report.results.length === 0 || report.results.some((result) => result.status === "fail") ? 1 : + report.results.some((result) => result.status === "blocked") ? 2 : 0; diff --git a/e2e/gates/production-parity.md b/e2e/gates/production-parity.md new file mode 100644 index 0000000..7df8e1f --- /dev/null +++ b/e2e/gates/production-parity.md @@ -0,0 +1,90 @@ +# Bidirectional production parity acceptance + +The latest scope is the reverse counterpart of the existing forward bridge. +The implementation is now wired into `serve`, setup, model discovery/listing, and +doctor. It remains opt-in; development has not activated it in the user's real +native configuration. + +## Latest usability acceptance + +The [2026-09-09 usability run](usability-2026-09-09.md) exercises live native coding, +cancellation/recovery, mixed-model concurrency through one proxy, setup, failures, +and genuine Claude credential refresh. It also records the defects found and corrected. + +## Live native acceptance + +On Codex CLI 0.153.4 with subscription authentication and real upstream model +discovery (no seeded model cache), the production gateway passed: + +| Parent | Claude child | Transport | Result | +| --- | --- | --- | --- | +| GPT-6 Astra | Sonnet 5 | WebSockets | Native tool read, result continuation, parent delivery, same-child follow-up | +| GPT-6 Astra | Opus 5 | WebSockets | Native tool read, result continuation and parent delivery | +| GPT-6 Astra | Fable 5.1 (`fable`) | WebSockets | Native tool read, result continuation and parent delivery | +| GPT-6 Astra | Sonnet 5 | Native HTTP fallback | Tool read, continuation, parent delivery and same-child follow-up | +| GPT-5.5 | Sonnet 5 | Native defaults | Configured child/tool round trip and parent delivery | + +The files contain unpredictable values absent from the prompts. Success requires +real Codex tool execution and the correct values reaching the parent. A later +Sonnet follow-up run also observed prompt-cache reads. The existing Claude Code → +OpenAI native Read/continuation test passed concurrently with reverse testing. + +```sh +node --import tsx e2e/gates/native-production.ts sonnet +node --import tsx e2e/gates/native-production.ts opus --no-followup +node --import tsx e2e/gates/native-production.ts fable --no-followup +node --import tsx e2e/gates/native-production.ts sonnet --http +node --import tsx e2e/gates/native-production.ts sonnet --no-followup --parent gpt-5.5 +``` + +The runner points native Codex at the actual production gateway, uses temporary +access-only credential copies, and lets native Codex fetch its own model catalog. +The optional HTTP relay rejects upgrades, then forwards HTTP bytes to the real +OpenAI endpoint. No model requests or tool responses are fabricated in these runs. +The native Claude binary is not used by this production runner. + +## Important corrections from prototype to production + +- Native Codex omits the bearer token on the local subscription endpoint while + retaining the account header. Production uses the existing Codex credential + manager with an exact account match, preserving explicit credentials and billing + mode. HTTP and upgrade authentication retries remain bounded. +- Native HTTP replay normalizes message content and can record streaming output + before opaque state is complete. The gateway issues an authenticated state handle + before text and commits its contents only after validated provider completion. + Semantic history matching tolerates serialization changes, not changed content. +- Tool calls are not executable before the terminal provider event. Text streams + incrementally, and errors, output limits, cancellation, backpressure and usage + accounting are represented in the native protocol. +- An upstream upgrade rejection is relayed once; closing its upstream cannot attach + another HTTP response to the same socket. + +## Parity boundary and follow-ups + +The [scope matrix](parity-scope.md) is authoritative. Included: per-model routing, +aliases, subscription authentication/refresh, native tool ownership, stream and +non-stream responses, prompt caching, process-local state, setup, discovery, +diagnostics and protected passthrough. + +Deferred for both directions: + +- [#45](https://github.com/dean0x/subswitch/issues/45): durable restart/resume and translated compaction. +- [#46](https://github.com/dean0x/subswitch/issues/46): conflict-safe setup undo. +- [#47](https://github.com/dean0x/subswitch/issues/47): explicit API authentication for translated inference. +- [#48](https://github.com/dean0x/subswitch/issues/48): broader content, hosted tools, structured output and cross-provider history capabilities. + +Native source-provider compaction remains passthrough. Translated compaction, +foreign opaque checkpoints, unsupported instruction placement and unsupported +tool/content forms receive explicit errors. API-key inference against Claude is +not silently substituted for subscription authentication. + +The native identity preamble is documented in README and SECURITY. Technical +acceptance does not establish a provider support commitment or confirm which +subscription/extra-usage quota was charged. + +## Automated and packaging verification + +Typechecking and all **852 tests** passed after the usability fixes. The installed-tarball smoke check +validated both the unchanged forward setup and the new `init --client both`, +`models --client codex --json`, and reverse-enabled server health. The existing +SSE benchmark passed all three stream shapes within its unchanged linearity bound. diff --git a/e2e/gates/provider-research.md b/e2e/gates/provider-research.md new file mode 100644 index 0000000..2290059 --- /dev/null +++ b/e2e/gates/provider-research.md @@ -0,0 +1,123 @@ +# Claude subscription implementations and remaining reverse work + +Follow-up: the [2026-09-07 live reverse prototype](live-reverse.md) now passes real +Codex/OpenAI/Claude tool round trips and same-child follow-ups. The findings below +record the preceding research stage; the linked report carries current acceptance +results and remaining production work. + +Investigated on 2026-09-06. Public source was downloaded at the revisions below +for inspection, without installing the projects or running their authentication +hooks. Live comparisons used the existing subscription, temporary native Claude +configuration, and OS-assigned loopback ports. No shared credential store or +running user session was changed. + +## What Pi and Hermes actually do + +| Project and inspected revision | Direct subscription request construction | +| --- | --- | +| [Pi](https://github.com/earendil-works/pi/blob/9767ba275f3e9a5ee0f5c5342249b629ab1b2282/packages/ai/src/api/anthropic-messages.ts), `9767ba275f3e` | Sends OAuth bearer auth to Anthropic Messages, adds OAuth/Claude Code betas, a Claude CLI user-agent, `x-app: cli`, and a Claude Code identity system block. Maps familiar tool names to native casing and maps replies back. Keeps the caller's system prompt after the identity block. | +| [Hermes](https://github.com/NousResearch/hermes-agent/blob/83467c28f78987acdeb55636fb625946ec572a30/agent/anthropic_adapter.py), `83467c28f789` | Uses the same Messages protocol with bearer auth and native identity headers/preamble. Additionally replaces product names in system text and aliases selected tool names/descriptions. These transformations are explicit in `_apply_claude_code_identity` and `_oauth_wire_namer`. | +| [pi-sub-anthropic](https://github.com/spksoft/pi-sub-anthropic/blob/f25ef58a799938dbb774c73729d5444eadddbfd7/docs/internals.md), `f25ef58a7999` | A separate extension derived from oh-my-pi. Reproduces a broader native fingerprint and billing attestation, prefixes tool names, and relocates the harness system prompt into synthetic conversation turns. Its own diagnostics attribute failures to prompt content. | + +These examples keep their own tool loops and call Messages directly; they are +relevant to a protocol bridge. They do not demonstrate a third-party subscription +contract without native identity claims. Relocating instructions into a user turn +also changes their instruction priority even when their text is preserved. + +Built-in OAuth support alone is not proof that included subscription quota works. +Pi has reports of [extra-usage billing](https://github.com/earendil-works/pi/issues/6979) +and [requests succeeding after a billing marker change](https://github.com/earendil-works/pi/issues/6421). +Those reports were closed as not planned. Hermes has a comparable +[native-success/direct-failure report](https://github.com/NousResearch/hermes-agent/issues/65564). +These are other users' observations, not diagnoses of this account. + +## What the local comparisons establish + +All direct variants below used SubSwitch's own user-agent. Native request bodies +were generated by the installed unmodified Claude binary for a fixed harmless +prompt, retained only in memory, and selectively reduced. No native identity or +billing signature was generated by the bridge. The tests execute no tools in +the replayed requests. + +| Comparison | Observed result | +| --- | --- | +| Existing direct forced-tool probe | Generic HTTP 429 | +| Add the second OAuth/Claude Code beta, with and without `x-app: cli` | Same generic HTTP 429 | +| Plain text without tools, with the additional beta | Same generic HTTP 429 | +| Native Sonnet parent → child → Read → parent through SubSwitch | Pass again; five Messages requests | +| Native-generated request, change only user-agent to SubSwitch | HTTP 200; completed stream verified after decoding compressed response | +| Native-generated body with minimal SubSwitch headers and the native beta selection | HTTP 200, completed stream and expected text | +| Minimal direct body with that same complete native beta selection | Generic HTTP 429 | +| Native body with metadata removed | HTTP 200, completed stream | +| Native body with billing block removed | HTTP 200, completed stream | +| Native body with both billing block and metadata removed | HTTP 200, completed stream and expected text | +| Remove the native identity preamble from that reduced body | Generic HTTP 429 | +| Replace native system blocks with a generic assistant instruction | Generic HTTP 429 | + +The native body with only the old minimal beta header returned HTTP 400. It +contains native feature fields, so reducing its beta selection is a different +experiment from reducing its identity. The system comparisons preserve the full +native beta selection on both sides. + +The successful reductions rule out a blanket inability to use this token from +Node, a requirement for the native user-agent, and a requirement for billing +attestation/account metadata in these requests. Removing the identity preamble +isolates a concrete request-content dependency that reproduces our 429. + +This does not reveal Anthropic's internal classifier, prove which quota was +charged, or establish that adding an identity string would suffice for arbitrary +Codex prompts and tools. The native-generated system context was retained in the +positive controls. No direct bridge-generated tool-result continuation is certified. +Neither blanket subscription exhaustion nor universal technical impossibility is +a defensible explanation of these results. + +The [reusable system control](claude-system-control.ts) runs a native control, +removes its billing block and metadata, compares identity retained/removed, then +restores the same request. It prints allowlisted booleans and statuses only: + +```sh +node --import tsx e2e/gates/claude-system-control.ts +``` + +Exit 0 means the identity-sensitive rejection was reproduced, **not** that the +reverse acceptance gate passed. Native startup and response bodies are bounded; +credentials and captures are not saved. The control does not synthesize a native +identity, recalculate an attestation, refresh credentials, or change billing mode. + +The committed runner reproduced **200 → 429 → 200** for identity retained, removed, +and restored. Its positive controls require text, `end_turn`, and `message_stop` +without an error event. Exact `gate-ok` task following varied on replay and is +reported separately; acceptance must not be confused with task correctness. +Typechecking and all **785 tests** passed after adding the diagnostic. + +## Consequence for the architecture + +The reference implementations explain the missing behavior, but their native +identity claims conflict with the original no-impersonation requirement. A direct +subscription contract allowing SubSwitch's own identity still needs to be +established. The support boundary is separate from the experimental result: +Anthropic directs third-party integrations to API/provider authentication and +restricts subscription credential intermediation, while distinguishing use of the +unmodified native binary. [Credential-use documentation](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) + +An explicitly selected API credential could validate protocol translation without +this subscription requirement. A native Claude runtime is another architecture, +with additional execution/session ownership questions. Neither has been selected +as a replacement for the requested pure bridge. + +## What remains to finish Codex → Claude + +Authentication is one gate; substantial reverse implementation remains. + +| Area | Current state and required work | +| --- | --- | +| Authentication | Claude credentials and API mode exist in development probes. Add production provider-specific auth/config, credential refresh/rotation handling, and credential isolation once the selected contract passes real tool continuation. | +| Routing and discovery | The production registry still contains only `codex`; Claude names are reserved for forward-path passthrough. Add ingress-aware Claude resolution/capabilities and augment native discovery without changing ordinary Claude-facing routing. | +| Request/tool translation | Add Responses/Responses-lite → Messages, embedded/namespaced/freeform tools, images/tool results, instruction ordering, tool choice, reasoning settings, and explicit unsupported-feature errors. Codex must keep tool execution. | +| Responses and transport | Add Messages → Responses streaming/non-streaming output, stable call/event identities and errors. Current HTTP/WebSocket ingress is raw passthrough; per-request model dispatch, compressed inspection and translated continuations remain. | +| Collaboration | Promote the experimental namespace adapter into the reverse-enabled path with consistent request, response, replay, and transport mappings; validate with live OpenAI parent and Claude child. | +| Durable state | Preserve thinking/signatures, restart/resume and fork history, bounded `previous_response_id` state, and compaction. Missing opaque state must fail explicitly. | +| Setup and release | Add native Codex setup/undo, global configuration layering, agent discovery and directional diagnostics. Verify v1/v2, tools, parent delivery, follow-ups, restart, compaction, both explicitly chosen auth modes, concurrent forward traffic, build, packaging and performance. | + +The research adds diagnostics and evidence only. It does not enable production +reverse routing, change native configuration, or establish release support. diff --git a/e2e/gates/reverse-adapter.ts b/e2e/gates/reverse-adapter.ts new file mode 100644 index 0000000..22ae362 --- /dev/null +++ b/e2e/gates/reverse-adapter.ts @@ -0,0 +1 @@ +export * from "../../src/claude-adapter.js"; diff --git a/e2e/gates/reverse-state.ts b/e2e/gates/reverse-state.ts new file mode 100644 index 0000000..0cc61cf --- /dev/null +++ b/e2e/gates/reverse-state.ts @@ -0,0 +1 @@ +export * from "../../src/claude-state.js"; diff --git a/e2e/gates/run.ts b/e2e/gates/run.ts new file mode 100644 index 0000000..10e54ac --- /dev/null +++ b/e2e/gates/run.ts @@ -0,0 +1,57 @@ +import { pathToFileURL } from "node:url"; +import { CredentialUnavailable, probeHeaders } from "./credentials.js"; +import { reportExitCode, runProbe, type GateAuth, type GateProvider } from "./probe.js"; + +export function parseArgs(args: readonly string[]): { + provider: GateProvider; auth: GateAuth; model: string; envName?: string; contract?: "native-history" | "native-arguments" | "schema-fields" | "namespace-control"; +} { + const values = new Map(); + for (let i = 0; i < args.length; i += 2) { + const flag = args[i]; const value = args[i + 1]; + if (!flag || !["--provider", "--auth", "--model", "--key-env", "--contract"].includes(flag) || + !value || value.startsWith("--") || values.has(flag)) throw new Error("invalid_arguments"); + values.set(flag, value); + } + const provider = values.get("--provider"); + const auth = values.get("--auth") ?? "subscription"; + const model = values.get("--model"); + const envName = values.get("--key-env"); + const contract = values.get("--contract"); + if ((provider !== "claude" && provider !== "openai") || (auth !== "api" && auth !== "subscription") || + !model || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(model) || + (envName !== undefined && (auth !== "api" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName))) || + (contract !== undefined && (provider !== "openai" || (contract !== "native-history" && contract !== "native-arguments" && contract !== "schema-fields" && contract !== "namespace-control")))) { + throw new Error("invalid_arguments"); + } + return { provider, auth, model, ...(envName === undefined ? {} : { envName }), ...(contract ? { contract } : {}) }; +} + +async function main() { + if (process.argv.slice(2).join(" ") === "--help") { + process.stdout.write("Usage: npm run probe:compat -- --provider openai|claude --model [--auth subscription|api] [--key-env NAME] [--contract native-history|native-arguments|schema-fields|namespace-control]\n" + + "Live, fixed-prompt checks. Subscription is the default. No tools are executed.\n" + + "Exit 0: selected checks pass; 1: contract/arguments fail; 2: credentials, network, or upstream rejection block the check.\n"); + return; + } + let options; + try { options = parseArgs(process.argv.slice(2)); } + catch { + process.stderr.write("Invalid probe arguments. Run npm run probe:compat -- --help.\n"); + process.exitCode = 1; return; + } + try { + const headers = await probeHeaders(options); + const report = await runProbe({ ...options, headers }); + process.stdout.write(JSON.stringify(report) + "\n"); + process.exitCode = reportExitCode(report); + } catch (error) { + const code = error instanceof CredentialUnavailable ? error.code : "probe_setup_failed"; + process.stdout.write(JSON.stringify({ + schemaVersion: 1, provider: options.provider, auth: options.auth, + results: [{ gate: "credentials", status: "blocked", code }], + }) + "\n"); + process.exitCode = 2; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); diff --git a/e2e/gates/upstream-reports.md b/e2e/gates/upstream-reports.md new file mode 100644 index 0000000..3661f94 --- /dev/null +++ b/e2e/gates/upstream-reports.md @@ -0,0 +1,150 @@ +# Upstream compatibility reports — drafts, not submitted + +Update: the [live prototype](live-reverse.md) passes the mixed-provider v2 flow and +follow-up with the native Claude preamble. The remaining Anthropic inquiry concerns +a supported production request/identity contract; basic technical tool continuation +is now demonstrated. No report has been submitted. + +These reports describe the two unresolved Gate 1 contracts. They contain fabricated +test data and public schema information only. No credentials, account identifiers, +request IDs, prompts from user sessions, or encrypted payloads are included. + +## OpenAI: supported plaintext collaboration contract for native Codex v2 + +### Problem and reproduction + +A local protocol bridge needs to route a native Codex child to another model +provider while Codex retains agent orchestration, permissions, and tool execution. +The bridge cannot decrypt OpenAI-owned collaboration messages. It needs a supported +way to request readable arguments for `spawn_agent`, `send_message`, and +`followup_task`, and to return a known-plaintext child message to its OpenAI parent. + +With Codex CLI 0.153.3, subscription authentication, and `gpt-6-astra`: + +```sh +npm ci +npm run probe:compat -- --provider openai --model gpt-6-astra +``` + +The [native namespace fixture](../../test/fixtures/native/codex-0.153.3-collaboration.json) +was captured using an isolated fake upstream. The runner sends the entire namespace +as a native `additional_tools` input item to the Codex Responses endpoint. A fixed +request with the unchanged namespace returns HTTP 200 and a completed SSE response. +The identical request with only three `parameters.properties.message.encrypted` +values changed from `true` to `false` returns HTTP 400. The error identifies the +reserved `collaboration.followup_task` schema as not matching the configured schema. +The dependent generated-argument stage is not reached. A separate corrected +known-plaintext history check now passes with the unchanged native schema: + +```sh +npm run probe:compat -- --provider openai --model gpt-6-astra --contract native-history +npm run probe:compat -- --provider openai --model gpt-6-astra --contract native-arguments +``` + +The installed client and backend accept `encrypted_function_args: []`, plaintext +`agent_message.content` arrays, and an `amsg`-prefixed message ID. The strings +`[plaintext arguments]` and `[plaintext]` were incorrectly inferred as wire values +in the earlier draft; they are log-redaction labels. The history probe is corrected. +In the separate outgoing-argument check, the model selects the requested Sonnet +child but returns an opaque task instead of the fixed readable message. + +Native v1 and v2 parent/child/tool-result flows now pass against a fabricated +upstream through SubSwitch over WebSockets. V2 follow-up and HTTP fallback also +pass. These establish the native-client contracts, not cross-provider live inference. + +The later per-field matrix rejects both false and omission independently for all +three tools. An ordinary `subswitch_collaboration` namespace instead accepts the +plaintext schemas and generates the exact readable task. A reversible experimental +adapter also passes the native Codex v2 fixture. This inquiry now concerns retaining +the reserved namespace specifically, rather than claiming every integration route +is blocked. See [the updated constraints](blockers.md). + +### Requested contract + +What supported native Codex configuration or request field opts these collaboration +messages into plaintext while keeping v2 orchestration and reserved schemas valid? +Does it apply to both subscription and explicitly API-authenticated Codex? +The incoming child-to-parent plaintext replay shape is already verified; the +remaining question is the outgoing task-generation contract. + +The public [beta Responses reference](https://developers.openai.com/api/reference/typescript/resources/beta/subresources/responses/methods/create) +describes text-bearing agent messages and server-hosted multi-agent configuration. +That does not establish a local Codex plaintext opt-in. Server-hosted orchestration +would change the required architecture, so it is not a substitute for this contract. + +### Resume criteria + +Document and reproduce a supported backend-accepted contract, then verify readable +generated arguments and a child reply with real native v2 Codex. Preserve v1 support +where selected natively. No ciphertext relabeling, decryption, or forced downgrade. +Plaintext schema changes cannot be enabled on the strength of HTTP 200 for the +unchanged control alone. + +## Anthropic: direct subscription compatibility and identity-sensitive HTTP 429 + +### Problem and reproduction + +The proposed bridge calls Messages directly while Codex executes the returned +tools. It does not run Claude Code as the agent executor. Before implementation, +we need a supported authentication contract and a successful tool-result continuation. + +Environment: macOS, Node 22.22.3, Claude Code 2.1.261. Read the user's existing +subscription credential from the native default Keychain service, then run: + +```sh +npm run probe:compat -- --provider claude --model claude-sonnet-5 +``` + +The probe uses the official Messages endpoint, bearer authentication, +`anthropic-version: 2023-06-01`, `anthropic-beta: oauth-2025-04-20`, and its own +SubSwitch probe user agent. It sends a fixed forced `echo` call with `max_tokens: +128`. It does not impersonate Claude Code or change credentials. HTTP 429 returns +`rate_limit_error`, generic message `Error`, and no `Retry-After`. No explicit spend +limit detail or inspected rate-limit reset guidance was present. The continuation +was not attempted after rejection. + +A separate unmodified native Claude Code control, using the same subscription and +model from an empty temporary directory with safe mode and no tools, succeeded with +the fixed answer `gate-ok`. A later isolated Sonnet parent → Sonnet child → Read → +parent test also passed through SubSwitch, including five Messages requests and a +real tool-result continuation. The direct request still failed with HTTPS and fetch, +streaming and non-streaming, and the beta query endpoint. +Direct plain-text requests without tools and adaptive-thinking requests with +automatic tool choice also returned 429. The existing Claude Code → OpenAI child +tool-result flow passed separately and concurrently with the isolated native Codex +v2 fixture; this does not test the reverse provider adapter. +This rules out treating native account access as universally unavailable. +A later [system-identity control and Pi/Hermes source comparison](provider-research.md) +narrows the difference further: a native-generated request with SubSwitch's own +user-agent and native betas succeeds without billing/account metadata, while +removing its native identity system block produces the generic 429. The internal +classification policy and billed quota are not established by this experiment. + +### Requested contract + +The [credential-use documentation](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) +restricts subscription credential intermediation and directs third-party products +to API authentication. Is there an approved direct subscription integration for a +user-operated local protocol bridge? If so, what request/authentication contract is +supported, including system identity requirements, and how should this specific 429 be diagnosed? If not, subscription mode +cannot be advertised as supported under the current requirement. + +### Resume criteria + +Establish a supported subscription integration, then pass both the forced tool call +and its tool-result continuation without native-client impersonation. Otherwise +the user must explicitly revise the subscription requirement before an API-only +release could be pursued. API mode has not been live-certified, would incur separate +billing, and would still require integrating and validating the experimental +OpenAI namespace adapter with the reverse provider. +An explicitly selected OpenAI API control returned HTTP 401; no Claude API key was +available. Neither API combination is certified. + +## Work disposition + +Keep the independently tested Codex raw passthrough disabled by default. Do not +activate reverse setup, dispatch Claude requests, or rewrite collaboration schemas +until their prerequisites pass. The ordinary namespace adapter can be investigated +independently; an OpenAI inquiry is specific to retaining its reserved schema. +Claude's supported authentication contract still needs resolution. These drafts +have not been sent. The full release matrix remains unverified. diff --git a/e2e/gates/usability-2026-09-09.md b/e2e/gates/usability-2026-09-09.md new file mode 100644 index 0000000..cb24f86 --- /dev/null +++ b/e2e/gates/usability-2026-09-09.md @@ -0,0 +1,84 @@ +# Native usability acceptance — 2026-09-09 + +Ran against `feat/bidirectional-parity`, starting at `2bfd0d7` and then repeating +the affected scenarios with the usability fixes. Clients: Codex CLI 0.153.4, +Claude Code 2.1.266, Node 22.22.3 on macOS. + +These runs used actual native clients and live model providers. Interactive setup +was driven through a real terminal; the longer Codex sessions were driven through +its native app-server protocol. The cancellation and continuation lifecycle follows +the [official app-server documentation](https://learn.chatgpt.com/docs/app-server). +Synthetic faults were used only for negative scenarios and the initial refresh-triggering 401. + +## Results + +| Scenario | Result | Observed control | +| --- | --- | --- | +| GPT-6 Astra → Sonnet, WebSockets | Pass | Actual file read, tool result, parent delivery, follow-up to the same child; prompt-cache reads observed. | +| GPT-6 Astra → Sonnet, HTTP fallback | Pass after fixes | Native fallback after controlled upgrade rejection; both unpredictable file values returned through tool/result follow-ups. | +| GPT-6 Astra → Opus | Pass | Native child read/tool-result/parent round trip. | +| GPT-6 Astra → Fable | Pass | Native child read/tool-result/parent round trip. | +| Claude Code → GPT-5.5 | Pass | Native Agent and Read calls, tool-result continuation, exact value returned. | +| Claude-backed native coding session | Pass | Inspected and patched `calculator.mjs`; independent execution confirmed `add(7, 5) === 12`. | +| GPT-6 Astra → Claude coding child | Pass | Child edited the file and ran commands; independently inspected and executed the resulting file. | +| Mixed native children | Pass | Two loaded child sessions, Sonnet and GPT-5.5 observed on actual gateway requests; four unpredictable values returned through separate follow-ups. | +| Both directions through one proxy concurrently | Pass | Mixed Codex children completed while native Claude Code performed a GPT-5.5 Read round trip through the same server; two forward translated requests completed. | +| Cancel then continue the same native conversation | Failed before fix; passes after | Turn reported interrupted, then the same thread completed a new request with the expected response. | +| Missing Claude credentials then restore | Pass after error fix | Native client shows Claude-specific sign-in/store guidance; restoring credentials allows the same conversation to proceed. | +| Controlled upstream 429 then recover | Pass | Native client surfaces 429 after its retry budget; fresh conversation succeeds once the injected fault is removed. | +| Restart proxy during a translated conversation | Expected limitation verified | Old state returns 409 with explicit new-conversation guidance; a fresh native conversation works. | +| Genuine Claude subscription refresh | Pass | One injected 401 → exactly one refresh → successful native tool/result continuation; rotated access/refresh tokens persisted, expiry valid, other credential fields preserved. | +| Interactive `init --client all` | Pass | Actual port prompt and writes; native comments/instructions, Claude permissions, and unrelated environment settings preserved. | +| Setup dry-run and repeat | Pass | Dry-run wrote no config; repeated setup with the same options was idempotent. | +| Fresh-shell serve/doctor | Pass | Proxy health available and `doctor --client all` exits 0. | +| User/project config precedence | Pass | Project alias overrides user default; another project sees the user default. | +| Custom upstream trust | Pass | Setup names the unapproved host and trust option, exits 1, and writes nothing. | +| `both` compatibility alias | Pass | Accepted by setup/model commands; model JSON matches canonical `all` output. | + +## Usability defects corrected + +1. Native cancellation retained an opaque replay handle whose state had never been + committed. The next turn failed with `missing_claude_replay_state`. A bounded empty + placeholder now allows the client's readable partial history to continue; opaque + thinking/tool content is still committed only at a valid terminal event. +2. Codex adds an ordered `` developer notice after an interruption. + That factual notice is retained at its original history position. Other unsupported + mid-history instructions still fail explicitly. +3. Local Claude credential errors used 401, which made native Codex try to refresh its + unrelated OpenAI login. They now use 503 with Claude-specific recovery guidance. +4. Lost-state errors now tell the user to start a new conversation. +5. `all` is the public client selector, backed by the supported-client registry; + `both` remains a compatibility alias. Adding a client still requires its integration. + +## Reproduce the existing live runners + +```sh +npm run probe:native-production -- sonnet +npm run probe:native-production -- sonnet --http +npm run probe:native-production -- opus --no-followup +npm run probe:native-production -- fable --no-followup +npm run probe:native-claude -- --openai +``` + +Additional session scenarios used `thread/start`, `turn/start`, `turn/interrupt`, +and `turn/completed` against an isolated native app-server. Coding tests verified +filesystem changes independently. Mixed-child tests observed model names at the +production gateway's resolution boundary without changing its decisions. Model validation uses actual request metadata and loaded-child counts. + +The local execution driver and redacted machine-readable evidence are archived under +`.devflow/docs/usability/feat-bidirectional-parity/2026-09-09/` (gitignored run artifacts). + +## Scope and cleanup + +Most runs used temporary profiles and access-only credential copies. The genuine +refresh test intentionally used the production Claude credential store and persisted +its rotated tokens; other store fields were preserved. Native settings were confined +to disposable profiles/projects, and each runner owned and cleaned up its processes. +No native binary, inherited hook, or real project setting was changed. + +Native Codex retries some 429/503 failures before displaying the final error. Durable +resume across proxy restarts and general mid-history instruction changes remain outside +the current parity scope. These results do not claim a visual desktop-UI review. + +The full regression suite passes 852 tests, including new cancellation-history and +credential-error controls. Typecheck, build, and installed-package smoke also pass. diff --git a/package-lock.json b/package-lock.json index 4ced6c9..f8468c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@clack/prompts": "^1.7.0", "picocolors": "^1.1.1", + "toml-eslint-parser": "^1.0.3", + "ws": "^8.21.3", "zod": "^4.4.3" }, "bin": { @@ -18,11 +20,12 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "@types/ws": "^8.18.1", "tsx": "^4.23.1", "typescript": "^7.0.2" }, "engines": { - "node": ">=22" + "node": "^22.15.0 || >=24" } }, "node_modules/@clack/core": { @@ -505,6 +508,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -887,6 +900,18 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -938,6 +963,21 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/toml-eslint-parser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-1.0.3.tgz", + "integrity": "sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, "node_modules/tsx": { "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", @@ -999,6 +1039,27 @@ "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 800c74c..3498eba 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "subswitch", "version": "0.4.0", "type": "module", - "description": "Local subscription-routing proxy for Claude Code — route a single subagent to a different model (OpenAI Codex) while every other request stays on Claude. Per-subagent routing, no API keys.", + "description": "Local subscription-routing proxy for native Claude Code and Codex sub-agents. Model-based routing with native tool execution.", "bin": { "subswitch": "dist/cli.js" }, @@ -35,7 +35,7 @@ "author": "dean0x", "license": "MIT", "engines": { - "node": ">=22" + "node": "^22.15.0 || >=24" }, "scripts": { "serve": "tsx src/cli.ts serve", @@ -44,18 +44,26 @@ "test": "node --import tsx --test --test-timeout=30000 \"test/unit/*.test.ts\" \"test/integration/*.test.ts\"", "bench:sse": "node --import tsx test/tools/sse-parser.bench.ts", "bench:memory": "node --import tsx test/tools/memory-concurrent-uploads.bench.ts", + "probe:compat": "tsx e2e/gates/run.ts", + "probe:native-codex": "tsx e2e/gates/native-codex.ts", + "probe:native-claude": "tsx e2e/gates/native-claude.ts", "check": "npm run typecheck && npm run test", "build": "tsc -p tsconfig.build.json", "prepack": "npm run build", - "prepublishOnly": "npm run check" + "prepublishOnly": "npm run check", + "probe:native-reverse": "tsx e2e/gates/native-reverse.ts", + "probe:native-production": "tsx e2e/gates/native-production.ts" }, "dependencies": { "@clack/prompts": "^1.7.0", "picocolors": "^1.1.1", + "toml-eslint-parser": "^1.0.3", + "ws": "^8.21.3", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.20.1", + "@types/ws": "^8.18.1", "tsx": "^4.23.1", "typescript": "^7.0.2" } diff --git a/scripts/smoke-tarball.sh b/scripts/smoke-tarball.sh index 66d413b..d1958a2 100755 --- a/scripts/smoke-tarball.sh +++ b/scripts/smoke-tarball.sh @@ -4,11 +4,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -# Pack the tarball from the repo root -cd "${REPO_ROOT}" -TARBALL="$(npm pack --json 2>/dev/null | node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); console.log(JSON.parse(d)[0].filename)")" -TARBALL_ABS="${REPO_ROOT}/${TARBALL}" - # Set up a temp install directory with cleanup on all exit paths TMPDIR_INSTALL="$(mktemp -d)" SERVE_PID="" @@ -18,10 +13,14 @@ cleanup() { kill "${SERVE_PID}" 2>/dev/null || true fi rm -rf "${TMPDIR_INSTALL}" - rm -f "${TARBALL_ABS}" } trap cleanup EXIT +# Keep packaging artifacts inside this run's temporary directory too. +cd "${REPO_ROOT}" +TARBALL="$(npm pack --pack-destination "${TMPDIR_INSTALL}" --json 2>/dev/null | node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); console.log(JSON.parse(d)[0].filename)")" +TARBALL_ABS="${TMPDIR_INSTALL}/${TARBALL}" + echo "Smoke: installing ${TARBALL} into ${TMPDIR_INSTALL}" cd "${TMPDIR_INSTALL}" npm install --save "${TARBALL_ABS}" >/dev/null 2>&1 @@ -32,26 +31,57 @@ npm install --save "${TARBALL_ABS}" >/dev/null 2>&1 VERSION_OUT="$(node_modules/.bin/subswitch --version)" echo " subswitch version: ${VERSION_OUT}" -# Start serve on isolated port 4941 to avoid dev-instance clash -echo '{"port": 4941}' > subswitch.config.json +# Ask the OS for an available port; never assume a developer port is unused. +SMOKE_PORT="$(node -e 'const net=require("node:net"); const s=net.createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close();});')" +node -e 'const fs=require("node:fs");fs.writeFileSync("subswitch.config.json",JSON.stringify({port:Number(process.argv[1]),providers:{codex:{authFile:process.cwd()+"/unused-auth.json"}}}),{mode:0o600});' "${SMOKE_PORT}" SUBSWITCH_CONFIG="${TMPDIR_INSTALL}/subswitch.config.json" node_modules/.bin/subswitch serve & SERVE_PID="$!" -# Poll up to 50 × 0.2 s = 10 s for the health endpoint -ATTEMPTS=0 -MAX=50 -until BODY="$(curl -sf "http://127.0.0.1:4941/__subswitch/health" 2>/dev/null)"; do - ATTEMPTS=$((ATTEMPTS + 1)) - if [ "${ATTEMPTS}" -ge "${MAX}" ]; then - echo "Smoke: health endpoint did not respond after ${MAX} attempts" >&2 +wait_for_health() { + # Poll up to 50 × 0.2 s = 10 s for the health endpoint + ATTEMPTS=0 + MAX=50 + until BODY="$(curl --max-time 1 -sf "http://127.0.0.1:${SMOKE_PORT}/__subswitch/health" 2>/dev/null)"; do + if ! kill -0 "${SERVE_PID}" 2>/dev/null; then + echo "Smoke: the isolated server exited before becoming healthy" >&2 + exit 1 + fi + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "${ATTEMPTS}" -ge "${MAX}" ]; then + echo "Smoke: health endpoint did not respond after ${MAX} attempts" >&2 + exit 1 + fi + sleep 0.2 + done + + if ! kill -0 "${SERVE_PID}" 2>/dev/null; then + echo "Smoke: the health response was not from the running test server" >&2 + exit 1 + fi + + if ! echo "${BODY}" | grep -q '"name":"subswitch"'; then + echo "Smoke: unexpected health response: ${BODY}" >&2 exit 1 fi - sleep 0.2 -done -if ! echo "${BODY}" | grep -q '"name":"subswitch"'; then - echo "Smoke: unexpected health response: ${BODY}" >&2 + echo "Smoke: OK — ${BODY}" +} + +wait_for_health + +# Exercise the installed reverse CLI without touching native user configuration. +kill "${SERVE_PID}" +wait "${SERVE_PID}" +SERVE_PID="" +CODEX_HOME="${TMPDIR_INSTALL}/native-codex" XDG_CONFIG_HOME="${TMPDIR_INSTALL}/user-config" \ + node_modules/.bin/subswitch init --client both --yes --port "${SMOKE_PORT}" >/dev/null +node -e 'const fs=require("node:fs");const p="subswitch.config.json";const c=JSON.parse(fs.readFileSync(p,"utf8"));c.codexIngress.claude.authFile=process.cwd()+"/unused-claude-auth.json";fs.writeFileSync(p,JSON.stringify(c),{mode:0o600});' +SUBSWITCH_CONFIG="${TMPDIR_INSTALL}/subswitch.config.json" node_modules/.bin/subswitch models --client codex --json > reverse-models.json +node -e 'const fs=require("node:fs");const m=JSON.parse(fs.readFileSync("reverse-models.json","utf8"));if(!m.enabled||!m.models.some(x=>x.id==="claude-sonnet-5"))process.exit(1);if(!fs.readFileSync("native-codex/config.toml","utf8").includes("openai_base_url"))process.exit(1);' +SUBSWITCH_CONFIG="${TMPDIR_INSTALL}/subswitch.config.json" node_modules/.bin/subswitch serve & +SERVE_PID="$!" +wait_for_health +if ! echo "${BODY}" | grep -q '"translationAvailable":true'; then + echo "Smoke: packaged reverse routing is not enabled" >&2 exit 1 fi - -echo "Smoke: OK — ${BODY}" diff --git a/src/anthropic-passthrough.ts b/src/anthropic-passthrough.ts index aaa9e2b..f0e4283 100644 --- a/src/anthropic-passthrough.ts +++ b/src/anthropic-passthrough.ts @@ -1,336 +1,16 @@ -import http from "node:http"; -import https from "node:https"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { toAnthropicErrorBody, SYNTHESIZED_HEADER, SYNTHESIZED_MARKER } from "./errors.js"; -import { drainRejectedUpload } from "./provider-transport.js"; -import type { Logger } from "./logger.js"; - -/** - * Hop-by-hop headers per RFC 7230 §6.1 — stripped in BOTH the request and response - * directions. These are connection-specific and must not be forwarded end-to-end. - * - * `host` is included: Node sets it on the outbound connection so the client-supplied - * value must not be forwarded. `proxy-connection` is a de-facto same-class extension. - * `connection` is managed by the keep-alive agent. - */ -const HOP_BY_HOP = new Set([ - "host", - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "proxy-connection", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]); - -/** - * Additional headers stripped from the response direction (upstream → client) only. - * - * The synthesized marker is removed from proxied responses so it stays - * authoritative: only the relay itself can assert it. The name is imported from - * errors.ts rather than restated, so a rename cannot reach the emitters while - * leaving this stripper matching the old name (ADR-008 chokepoint). It is NOT - * stripped from the request direction because: - * - The relay never reads the request-side value anywhere. - * - The header is not hop-by-hop. - * - With subswitch turned off, the header would reach the origin untouched. - * Stripping it from client requests is relay-invented behaviour — the kind - * ADR-010 prohibits. - */ -const RESPONSE_STRIP = new Set([...HOP_BY_HOP, SYNTHESIZED_HEADER]); - -/** - * Build a filtered flat [name, value, ...] array from a rawHeaders array, - * skipping headers listed in `strip` case-insensitively while preserving the - * original casing, order, and duplicates of every non-stripped header. - * - * The `strip` parameter is REQUIRED so every call site must declare its direction: - * - Response direction (upstream → client): pass RESPONSE_STRIP - * - Request direction (client → upstream): pass HOP_BY_HOP - * - * Node's http.ServerResponse.writeHead() accepts the flat-array form directly - * (via _storeHeader's Array branch). For the request direction we use setHeader - * calls instead (http.request options.headers uses Object.keys, not flat pairs). - */ -const filterRawHeaders = (rawHeaders: readonly string[], strip: ReadonlySet): string[] => { - const filtered: string[] = []; - for (let i = 0; i + 1 < rawHeaders.length; i += 2) { - const name = rawHeaders[i]!; - const value = rawHeaders[i + 1]!; - if (!strip.has(name.toLowerCase())) { - filtered.push(name, value); - } - } - return filtered; -}; - -export interface PassthroughOptions { - readonly baseUrl: string; - /** - * Bounds TCP connection establishment only (milliseconds). - * The timer is armed directly on the socket (not via `ClientRequest.setTimeout`, - * which defers internally and cannot bound the connect phase). Once TCP connects, - * the timer is disarmed entirely — the relay must never bound the headers or stream - * phases on a connected client (ADR-010). - * - * On HTTPS connections, `'connect'` fires after TCP establishment but before the - * TLS handshake, so TLS negotiation is NOT covered by this budget — neither the - * headers phase nor the stream phase is bounded, consistent with ADR-010. - * - * For pooled/keep-alive sockets (no connect phase), this budget has no effect. - */ - readonly connectTimeoutMs: number; - readonly logger: Logger; - /** Maximum sockets in the keep-alive pool. Config key: anthropic.maxUpstreamSockets. */ - readonly maxUpstreamSockets: number; - /** - * Test seam — provide a pre-built Agent to override the auto-created one. - * Production code omits this; the forwarder creates a keep-alive agent - * matching the base URL protocol. - */ - readonly agent?: http.Agent; -} - -/** - * Describes how much of the request body the caller has already read. - * - * "complete" — the full body is in `bytes`; nothing remains on `req`. The forwarder - * calls `upstream.end(bytes)` and never pipes `req`. This is the common path for - * bodies that fit within the routing window. - * - * "prefix" — `bytes` holds bytes that were already read (possibly empty); the rest - * is still flowing on `req`. The forwarder writes the prefix then pipes `req`. - * This is the over-window path: we read enough to sniff the model, then stream. - * - * Invariant: `pipe()` on a readable that has already emitted `end` never ends the - * destination — the discriminant makes that hang unrepresentable. - */ -export type ForwardedBody = - | { readonly kind: "complete"; readonly bytes: Buffer } - | { readonly kind: "prefix"; readonly bytes: Buffer }; - -export type AnthropicForwarder = (req: IncomingMessage, res: ServerResponse, body?: ForwardedBody) => void; - -export const createAnthropicForwarder = (options: PassthroughOptions): AnthropicForwarder => { - const target = new URL(options.baseUrl); - const client = target.protocol === "https:" ? https : http; - const basePath = target.pathname === "/" ? "" : target.pathname.replace(/\/$/, ""); - - // Create a keep-alive agent for persistent connections to the upstream. - // This matches Claude Code's own direct connection behaviour (parity). - const agentOpts: http.AgentOptions = { keepAlive: true, maxSockets: options.maxUpstreamSockets, scheduling: "lifo" }; - const agent = options.agent ?? (target.protocol === "https:" ? new https.Agent(agentOpts) : new http.Agent(agentOpts)); - - // Sentinel for "no bytes consumed yet" — the prefix path with an empty prefix. - // Shared across calls so allocation is constant rather than per-request. - const EMPTY_PREFIX: ForwardedBody = { kind: "prefix", bytes: Buffer.alloc(0) }; - - return (req, res, body) => { - const path = `${basePath}${req.url ?? "/"}`; - // Normalise: omitted means nothing has been read yet (prefix path, empty prefix). - const consumed = body ?? EMPTY_PREFIX; - // True when req still has bytes to deliver (prefix path). - const streaming = consumed.kind === "prefix"; - // One-way latch over a four-outcome terminal state machine: - // 1. upstream response headers — relayed verbatim - // 2. connect-phase timeout — synthesized 504 - // 3. upstream error — synthesized 502 - // 4. client disconnect, or a client stream error on the unbuffered path - // The first to claim it owns the client-visible outcome and the warn log; the - // rest return early, so a destroy() issued by one handler cannot produce a - // duplicate anthropic_upstream_error warn or write into a response that is - // already written or destroyed. - // - // It answers one question — has the client's outcome been decided? — and only - // that one. Resource teardown has its own predicate (res.writableFinished), - // because this latch is spent the instant headers are relayed, which is before - // the mid-stream abort teardown has to handle (PF-022). - let settled = false; - const settle = (): boolean => { - if (settled) return false; - settled = true; - return true; - }; - - const upstream = client.request( - { - protocol: target.protocol, - hostname: target.hostname, - ...(target.port !== "" ? { port: Number(target.port) } : {}), - method: req.method ?? "GET", - path, - agent, - // No headers here — http.request uses Object.keys on the headers option, - // which would produce numeric indices for an array. We apply headers via - // setHeader calls below to preserve original casing and per-name value order - // (cross-name position of interleaved duplicates is not guaranteed). - }, - // Terminal outcome 1 (upstream response headers). - (upstreamRes) => { - if (!settle()) return; - // Response direction: writeHead accepts a flat [name, value, ...] array - // directly (Node's _storeHeader Array branch), preserving the upstream's - // original header casing, order, and duplicates byte-for-byte. - // filterRawHeaders strips SYNTHESIZED_HEADER so an origin that sets it - // cannot impersonate the relay's synthesized-response marker. - res.writeHead(upstreamRes.statusCode ?? 502, filterRawHeaders(upstreamRes.rawHeaders, RESPONSE_STRIP)); - res.socket?.setNoDelay(true); - upstreamRes.pipe(res); - upstreamRes.on("error", () => res.destroy()); - }, - ); - - // Timer arming — single-budget design (ADR-010): - // - // connectTimeoutMs — bounds TCP establishment ONLY, armed DIRECTLY on the - // socket (not via upstream.setTimeout). ClientRequest.setTimeout() defers - // internally via its own 'connect' listener, so it would fire only after - // connect and cannot bound the connect phase. - // - // Node v22's internal socket-timeout handler (onTimeout) skips - // req.emit('timeout') when socket.connecting is true, so we propagate the - // timeout manually via upstream.emit('timeout'). - // - // On connect the timer is DISARMED entirely: the relay must never bound the - // headers-phase or stream-phase on a connected client (ADR-010). - // - // For pooled/keep-alive sockets (no connect phase) this budget has no effect. - upstream.on("socket", (socket) => { - socket.setNoDelay(true); - if (socket.connecting) { - socket.setTimeout(options.connectTimeoutMs); - const onConnectTimeout = () => { - socket.removeListener("connect", onConnect); - socket.setTimeout(0); // disarm before manual propagation - upstream.emit("timeout"); // triggers our handler → 504 - }; - const onConnect = () => { - socket.removeListener("timeout", onConnectTimeout); - socket.setTimeout(0); // disarm — no further timers (ADR-010) - }; - socket.once("timeout", onConnectTimeout); - socket.once("connect", onConnect); - } - // Pooled/keep-alive socket: connect phase already done, no timer to arm. - }); - - // Request direction: build a Map from the filtered rawHeaders so that - // duplicates (same lowercase key, different values) are preserved as array - // values, and setHeader sends them with original name casing. Caveat: - // interleaved duplicates of the same name (A,B,A) are regrouped adjacent - // (A,A,B); per-name value order is preserved (RFC 7230 §3.2.2) and adjacent - // duplicates are byte-exact. Anthropic clients do not interleave duplicate - // header names, so this is safe in practice. - const filteredRaw = filterRawHeaders(req.rawHeaders, HOP_BY_HOP); - const headerMap = new Map(); - for (let i = 0; i + 1 < filteredRaw.length; i += 2) { - const name = filteredRaw[i]!; - const value = filteredRaw[i + 1]!; - const key = name.toLowerCase(); - const entry = headerMap.get(key); - if (entry === undefined) { - headerMap.set(key, { name, values: [value] }); - } else { - entry.values.push(value); - } - } - for (const { name, values } of headerMap.values()) { - upstream.setHeader(name, values.length === 1 ? values[0]! : values); - } - - // Terminal outcome 2 (connect timeout): only the connect-phase timeout can fire; - // no other timer is ever armed. settle() ensures the error handler does - // not produce a duplicate warn after destroy() is called. - // - // On the unbuffered path the client is often still uploading when this fires, and - // its bytes are piped into the upstream that is about to be destroyed. The upload - // is unpiped and handed to drainRejectedUpload, which reads it out under the same - // time and byte bounds the 413 path uses. Without that, the 504 is written but the - // connection is dead weight: Node cannot parse the next request out of a body it - // never consumed, so the socket is held until server.requestTimeout (600 s) with - // the client still pushing into it (measured). The buffered path needs none of - // this — the request was fully consumed before the upstream was opened. - upstream.on("timeout", () => { - if (!settle()) return; - options.logger.log("warn", "anthropic_upstream_timeout", { path: req.url ?? "/" }); - if (!res.headersSent) { - res.writeHead(504, { "content-type": "application/json", [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER }); - res.end(toAnthropicErrorBody("api_error", "upstream timed out")); - } else { - res.destroy(); - } - if (streaming) { - req.unpipe(upstream); - drainRejectedUpload(req); - } - upstream.destroy(); - }); - - // Terminal outcome 3 (upstream error): settle() prevents a double-warn if - // destroy() from the timeout handler produces an ECONNRESET on the next tick. - // - // The unbuffered path needs the same upload drain as the timeout handler above, - // for the same reason and under the same bounds — the client is often still - // uploading into an upstream that has already failed, and a 502 written onto a - // connection whose body was never consumed leaves it dead weight until - // server.requestTimeout (measured — I-079 sibling, applies ADR-010). - upstream.on("error", () => { - if (!settle()) return; - options.logger.log("warn", "anthropic_upstream_error", { path: req.url ?? "/" }); - if (!res.headersSent) { - res.writeHead(502, { "content-type": "application/json", [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER }); - res.end(toAnthropicErrorBody("api_error", "upstream connection failed")); - } else { - res.destroy(); - } - if (streaming) { - req.unpipe(upstream); - drainRejectedUpload(req); - } - }); - - // Terminal outcome 4 (client disconnect). - // - // Teardown is unconditional on an unfinished response: `res.writableFinished` is - // the predicate, never the latch (PF-022). `pipe()` does not propagate - // destination teardown to the source, so an upstream left alive here stays - // half-read and its socket never returns to the agent's free pool; at - // maxUpstreamSockets the pool exhausts and every later request queues inside - // http.Agent indefinitely — a hang no origin can produce (ADR-010). - // - // The latch is claimed on the way through so the ECONNRESET that destroy() raises - // a tick later cannot warn or write a 502 into a closed response. Its return is - // discarded: a client abort owns this outcome either way, and is normal — no warn - // log, no synthetic HTTP response. - res.on("close", () => { - if (res.writableFinished) return; - void settle(); - upstream.destroy(); - }); - - if (consumed.kind === "complete") { - // Full body already buffered — end the upstream in one write. No pipe, no - // client-stream error handler needed: req has delivered everything it will. - upstream.end(consumed.bytes); - } else { - // Prefix path: write the bytes we already read, then pipe the remainder. - // Invariant (stated in ForwardedBody): pipe() on a readable that has already - // emitted "end" never ends the destination — the discriminant makes that hang - // unrepresentable. - if (consumed.bytes.length > 0) upstream.write(consumed.bytes); - req.pipe(upstream); - // A failed client stream takes the upstream down with it, and claims the - // settlement on the way out for the same reason the res "close" handler does: - // the ECONNRESET that destroy() raises a tick later must not be reported as an - // origin failure or answered with a 502 the origin never sent. The return is - // discarded — a client fault owns the outcome whether or not the latch was open. - req.on("error", () => { - void settle(); - upstream.destroy(); - }); - } - }; -}; +import { toAnthropicErrorBody } from "./errors.js"; +import { createRawHttpForwarder, type PassthroughOptions as RawOptions, type ForwardedBody } from "./raw-http-passthrough.js"; + +export type { ForwardedBody } from "./raw-http-passthrough.js"; +export type PassthroughOptions = Omit; +export type AnthropicForwarder = ((req: IncomingMessage, res: ServerResponse, body?: ForwardedBody) => void) & { close?(): void }; + +/** Existing Claude-facing transport, with its original errors and connect-only timeout. */ +export const createAnthropicForwarder = (options: PassthroughOptions): AnthropicForwarder => + createRawHttpForwarder({ + ...options, + errorBody: (message) => toAnthropicErrorBody("api_error", message), + logPath: (req) => req.url ?? "/", + events: { timeout: "anthropic_upstream_timeout", error: "anthropic_upstream_error" }, + }); diff --git a/src/claude-adapter.ts b/src/claude-adapter.ts new file mode 100644 index 0000000..1c9a2f4 --- /dev/null +++ b/src/claude-adapter.ts @@ -0,0 +1,416 @@ +/** Responses/Responses-lite and Anthropic Messages protocol translation. */ +import { createHash, randomUUID } from "node:crypto"; +import { object, type ObjectValue as Item } from "./plain-object.js"; +import { replayIdentity, type ReverseState } from "./claude-state.js"; + +export type { ObjectValue as Item } from "./plain-object.js"; +import { ReverseContractError, type ClaudeErrorCode } from "./claude-errors.js"; +export { ReverseContractError } from "./claude-errors.js"; +const fail = (code: ClaudeErrorCode): never => { + throw new ReverseContractError(code); +}; +const string = (value: unknown): string => (typeof value === "string" ? value : fail("expected_string")); +const item = (value: unknown): Item => object(value) ?? fail("expected_object"); +export interface ToolMapping { + wire: string; + name: string; + namespace?: string; + type: "function" | "custom"; + definition: Item; +} +export interface ReverseRequest { + body: Item; + tools: ReadonlyMap; +} + +export function toolWireName(namespace: string | undefined, name: string, type: string): string { + return `ss_${createHash("sha256") + .update(JSON.stringify([namespace ?? null, name, type])) + .digest("hex") + .slice(0, 32)}`; +} + +function content(value: unknown, toolResult = false): Item[] { + if (typeof value === "string") return [{ type: "text", text: value }]; + if (!Array.isArray(value)) return fail("unsupported_content"); + return value.map((value) => { + const block = item(value); + if (["input_text", "output_text", "text"].includes(String(block["type"]))) + return { type: "text", text: string(block["text"]) }; + if (block["type"] === "input_image") { + const url = string(block["image_url"]); + const inline = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(url); + if (inline) return { type: "image", source: { type: "base64", media_type: inline[1], data: inline[2] } }; + if (/^https:\/\//.test(url)) return { type: "image", source: { type: "url", url } }; + return fail("unsupported_image"); + } + return fail(toolResult ? "unsupported_tool_result_content" : "unsupported_content_block"); + }); +} + +const collectTools = (request: Item, input: Item[]): ReadonlyMap => { + const tools = new Map(); + if (request["tools"] !== undefined && !Array.isArray(request["tools"])) return fail("invalid_tools"); + const register = (definition: Item, namespace?: string): void => { + if (definition["type"] === "namespace") { + if (namespace || !Array.isArray(definition["tools"])) return fail("unsupported_namespace"); + for (const nested of definition["tools"]) register(item(nested), string(definition["name"])); + return; + } + const type = definition["type"]; + if (type !== "function" && type !== "custom") return fail("unsupported_hosted_tool"); + const name = string(definition["name"]), + wire = toolWireName(namespace, name, type); + const existing = tools.get(wire); + if (existing && JSON.stringify(existing.definition) !== JSON.stringify(definition)) + return fail("tool_definition_conflict"); + tools.set(wire, { wire, name, type, definition, ...(namespace === undefined ? {} : { namespace }) }); + }; + if (Array.isArray(request["tools"])) for (const definition of request["tools"]) register(item(definition)); + for (const entry of input) + if (entry["type"] === "additional_tools") { + if (!Array.isArray(entry["tools"])) return fail("invalid_additional_tools"); + for (const definition of entry["tools"]) register(item(definition)); + } + return tools; +}; + +const lookupTool = (tools: ReadonlyMap, entry: Item, type: "function" | "custom"): ToolMapping => { + const namespace = entry["namespace"] === undefined ? undefined : string(entry["namespace"]); + return tools.get(toolWireName(namespace, string(entry["name"]), type)) ?? fail("unknown_tool"); +}; + +const buildSystem = (request: Item, nativePreamble: readonly Item[]): Item[] => { + const system: Item[] = [...nativePreamble]; + if (request["instructions"] !== undefined && request["instructions"] !== "") { + system.push({ type: "text", text: string(request["instructions"]) }); + } + return system; +}; + +const foldHistory = (input: Item[], system: Item[], tools: ReadonlyMap, state?: ReverseState) => { + const messages: { role: "user" | "assistant"; content: Item[] }[] = []; + const append = (role: "user" | "assistant", blocks: Item[]) => { + if (!blocks.length) return; + const last = messages.at(-1); + if (last?.role === role) last.content.push(...blocks); + else messages.push({ role, content: blocks }); + }; + const calls = new Set(), + results = new Set(); + for (let inputIndex = 0; inputIndex < input.length; inputIndex++) { + const entry = input[inputIndex]!; + const type = entry["type"]; + if (type === "additional_tools") continue; + if (type === "reasoning" && state) { + const replay = state.open(string(entry["encrypted_content"])); + const following = input.slice(inputIndex + 1, inputIndex + 1 + replay.output.length); + if ( + following.some( + (value) => + value["encrypted_function_args"] !== undefined && + (!Array.isArray(value["encrypted_function_args"]) || value["encrypted_function_args"].length), + ) + ) + return fail("encrypted_tool_arguments"); + if ( + following.length !== replay.output.length || + following.some((value, index) => replayIdentity(value) !== replayIdentity(replay.output[index]!)) + ) { + return fail("state_history_mismatch"); + } + for (const block of replay.content) + if (block["type"] === "tool_use") { + const id = string(block["id"]); + if (calls.has(id)) return fail("duplicate_tool_call"); + calls.add(id); + } + append("assistant", replay.content); + inputIndex += following.length; + continue; + } + if (type === "message" || (type === undefined && entry["role"] !== undefined)) { + const role = entry["role"]; + if (role === "system" || role === "developer") { + const blocks = content(entry["content"]); + if (blocks.some((block) => block["type"] !== "text")) return fail("nontext_system"); + if (messages.length) { + // Native Codex records cancellation as an ordered developer notice. It + // describes the interrupted turn, so retain it at that point in history; + // hoisting it into the system prompt would make it apply to later turns. + const text = blocks.length === 1 ? blocks[0]?.["text"] : undefined; + if (role === "developer" && typeof text === "string" && /^\n[^]*\n<\/turn_aborted>$/.test(text)) { + append("user", blocks); + continue; + } + return fail("mid_history_instructions_unimplemented"); + } + system.push(...blocks); + } else if (role === "user" || role === "assistant") append(role, content(entry["content"])); + else return fail("unsupported_message_role"); + } else if (type === "agent_message") { + const blocks = content(entry["content"]); + append("user", [ + { type: "text", text: `Agent message from ${string(entry["author"])} to ${string(entry["recipient"])}:` }, + ...blocks, + ]); + } else if (type === "function_call" || type === "custom_tool_call") { + if ( + entry["encrypted_function_args"] !== undefined && + (!Array.isArray(entry["encrypted_function_args"]) || entry["encrypted_function_args"].length) + ) + return fail("encrypted_tool_arguments"); + const mapping = lookupTool(tools, entry, type === "function_call" ? "function" : "custom"); + const id = string(entry["call_id"]); + if (calls.has(id)) return fail("duplicate_tool_call"); + calls.add(id); + let args: Item; + try { + args = + type === "custom_tool_call" + ? { input: string(entry["input"]) } + : item(JSON.parse(string(entry["arguments"]))); + } catch { + return fail("invalid_tool_arguments"); + } + append("assistant", [{ type: "tool_use", id, name: mapping.wire, input: args }]); + } else if (type === "function_call_output" || type === "custom_tool_call_output") { + const id = string(entry["call_id"]); + if (!calls.has(id) || results.has(id)) return fail("unmatched_tool_result"); + results.add(id); + append("user", [{ type: "tool_result", tool_use_id: id, content: content(entry["output"], true) }]); + } else + return fail( + type === "reasoning" || type === "compaction" ? "opaque_state_unimplemented" : "unsupported_input_item", + ); + } + if ([...calls].some((id) => !results.has(id))) return fail("missing_tool_result"); + return messages; +}; + +const mapTools = (tools: ReadonlyMap) => { + return [...tools.values()].map((mapping) => { + const definition = mapping.definition; + const description = definition["description"] === undefined ? "" : string(definition["description"]); + return { + name: mapping.wire, + description: + mapping.type === "custom" + ? `${description}\nReturn the original freeform tool input verbatim in the input string. Tool format: ${JSON.stringify(definition["format"] ?? { type: "text" })}` + : description, + input_schema: + mapping.type === "custom" + ? { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + } + : item(definition["parameters"] ?? { type: "object", properties: {} }), + }; + }); +}; + +const resolveToolChoice = (request: Item, tools: ReadonlyMap): Item => { + let choice: Item = { type: "auto" }; + if (request["tool_choice"] === "none") choice = { type: "none" }; + else if (request["tool_choice"] === "required") choice = { type: "any" }; + else if (object(request["tool_choice"])) { + const selected = item(request["tool_choice"]); + if (selected["type"] !== "function" && selected["type"] !== "custom") return fail("unsupported_tool_choice"); + choice = { type: "tool", name: lookupTool(tools, selected, selected["type"]).wire }; + } else if (request["tool_choice"] !== undefined && request["tool_choice"] !== "auto") + return fail("unsupported_tool_choice"); + if (request["parallel_tool_calls"] === false && choice["type"] !== "none") choice["disable_parallel_tool_use"] = true; + return choice; +}; + +const sampling = (request: Item): Item => { + const format = object(object(request["text"])?.["format"]); + if (format && format["type"] !== "text") return fail("structured_output_unimplemented"); + const max = request["max_output_tokens"] ?? 4096; + if (typeof max !== "number" || !Number.isSafeInteger(max) || max < 1) return fail("invalid_output_limit"); + const reasoning = object(request["reasoning"]); + const effort = reasoning?.["effort"]; + if (effort !== undefined && !["none", "low", "medium", "high", "xhigh", "max"].includes(String(effort))) + return fail("unsupported_reasoning_effort"); + return { + max_tokens: max, + ...(effort === "none" + ? { thinking: { type: "disabled" } } + : effort !== undefined + ? { thinking: { type: "adaptive" }, output_config: { effort } } + : {}), + }; +}; + +/** Caller supplies complete native history. Unknown opaque state is an explicit error. */ +export const reverseRequest = ( + request: Item, + nativePreamble: readonly Item[] = [], + state?: ReverseState, +): ReverseRequest => { + const input = + typeof request["input"] === "string" + ? [{ type: "message", role: "user", content: request["input"] }] + : Array.isArray(request["input"]) + ? request["input"].map(item) + : fail("unsupported_input"); + const tools = collectTools(request, input); + const system = buildSystem(request, nativePreamble); + const messages = foldHistory(input, system, tools, state); + const mappedTools = mapTools(tools); + const choice = resolveToolChoice(request, tools); + return { + tools, + body: { + model: string(request["model"]), + ...sampling(request), + stream: false, + cache_control: { type: "ephemeral" }, + ...(system.length ? { system } : {}), + messages, + ...(mappedTools.length ? { tools: mappedTools, tool_choice: choice } : {}), + }, + }; +}; + +/** Completed JSON only. Partial/truncated arguments never become executable native calls. */ +export function reverseResponse( + response: Item, + request: ReverseRequest, + state?: ReverseState, + identities?: ReadonlyMap, + stateToken?: string, +): Item[] { + const upstreamString = (value: unknown): string => + typeof value === "string" ? value : fail("invalid_claude_response"); + const upstreamItem = (value: unknown): Item => object(value) ?? fail("invalid_claude_response"); + if (response["type"] !== "message" || !Array.isArray(response["content"])) return fail("invalid_claude_response"); + if (response["stop_reason"] !== "end_turn" && response["stop_reason"] !== "tool_use") + return fail("incomplete_claude_response"); + const output: Item[] = []; + const callIds = new Set(); + for (const [blockIndex, value] of response["content"].entries()) { + const block = upstreamItem(value); + if (block["type"] === "text") { + upstreamString(block["text"]); + output.push({ + type: "message", + id: identities?.get(blockIndex) ?? `msg_${randomUUID()}`, + role: "assistant", + phase: response["stop_reason"] === "tool_use" ? "commentary" : "final_answer", + status: "completed", + content: [{ type: "output_text", text: block["text"], annotations: [] }], + }); + } else if (block["type"] === "tool_use") { + const mapping = request.tools.get(upstreamString(block["name"])) ?? fail("unknown_claude_tool"); + const args = upstreamItem(block["input"]), + callId = upstreamString(block["id"]); + if (!callId || callIds.has(callId)) return fail("duplicate_or_missing_tool_call_id"); + callIds.add(callId); + const common = { + id: identities?.get(blockIndex) ?? `fc_${randomUUID()}`, + call_id: callId, + name: mapping.name, + status: "completed", + ...(mapping.namespace === undefined ? {} : { namespace: mapping.namespace }), + }; + if (mapping.type === "custom") { + if (Object.keys(args).length !== 1) return fail("invalid_custom_tool_input"); + output.push({ ...common, type: "custom_tool_call", input: upstreamString(args["input"]) }); + } else + output.push({ + ...common, + type: "function_call", + arguments: JSON.stringify(args), + ...(mapping.namespace === "collaboration" ? { encrypted_function_args: [] } : {}), + }); + } else if (block["type"] === "thinking" || block["type"] === "redacted_thinking") { + if (!state) return fail("claude_thinking_state_unimplemented"); + } else return fail("unsupported_claude_output"); + } + const toolCount = output.filter((entry) => entry["type"] !== "message").length; + if ((response["stop_reason"] === "tool_use") !== toolCount > 0) return fail("inconsistent_stop_reason"); + if (state) { + output.unshift({ + type: "reasoning", + id: `rs_${randomUUID()}`, + summary: [], + encrypted_content: stateToken ?? state.seal({ content: response["content"] as Item[], output: [...output] }), + }); + } + return output; +} + +/** Responses event construction, independent of HTTP versus WebSockets. */ +export function reverseEvents(id: string, model: string, output: Item[], usage: Item = {}): Item[] { + const base = { + id, + object: "response", + created_at: Math.floor(Date.now() / 1000), + model, + status: "in_progress", + output: [], + }; + const frames: Item[] = [ + { type: "response.created", response: base }, + { type: "response.in_progress", response: base }, + ]; + output.forEach((entry, output_index) => { + const type = entry["type"], + item_id = entry["id"]; + frames.push({ + type: "response.output_item.added", + output_index, + item: { + ...entry, + status: "in_progress", + ...(type === "function_call" + ? { arguments: "" } + : type === "custom_tool_call" + ? { input: "" } + : { content: [] }), + }, + }); + if (type === "message") { + const part = (entry["content"] as Item[])[0]!; + const info = { item_id, output_index, content_index: 0 }; + frames.push( + { type: "response.content_part.added", ...info, part: { ...part, text: "" } }, + { type: "response.output_text.delta", ...info, delta: part["text"] }, + { type: "response.output_text.done", ...info, text: part["text"] }, + { type: "response.content_part.done", ...info, part }, + ); + } else if (type === "function_call" || type === "custom_tool_call") { + const custom = type === "custom_tool_call", + field = custom ? "input" : "arguments"; + const stem = custom ? "response.custom_tool_call_input" : "response.function_call_arguments"; + frames.push( + { type: `${stem}.delta`, item_id, output_index, delta: entry[field] }, + { type: `${stem}.done`, item_id, output_index, [field]: entry[field] }, + ); + } + frames.push({ type: "response.output_item.done", output_index, item: entry }); + }); + const count = (value: unknown) => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0; + const cached = count(usage["cache_read_input_tokens"]); + const inputTokens = count(usage["input_tokens"]) + count(usage["cache_creation_input_tokens"]) + cached; + const outputTokens = count(usage["output_tokens"]); + frames.push({ + type: "response.completed", + response: { + ...base, + status: "completed", + output, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + input_tokens_details: { cached_tokens: cached }, + }, + }, + }); + return frames.map((frame, sequence_number) => ({ ...frame, sequence_number })); +} diff --git a/src/claude-auth.ts b/src/claude-auth.ts new file mode 100644 index 0000000..afaa7b3 --- /dev/null +++ b/src/claude-auth.ts @@ -0,0 +1,309 @@ +import type { Readable, Writable } from "node:stream"; +import { CLAUDE_EVENTS } from "./provider-events.js"; +import { execFile, spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { readFile, open, rename, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { object } from "./claude-contract.js"; +import { expandHome, type ClaudeProviderConfig } from "./config.js"; +import { ok, err, type Result } from "./result.js"; +import type { ProxyError } from "./errors.js"; +import type { Logger } from "./logger.js"; +import { readBoundedText } from "./provider-transport.js"; + +const exec = promisify(execFile); +const authError = (message: string): ProxyError => ({ kind: "auth", message }); +export interface ClaudeCredential { + readonly provider: "claude"; + readonly authHeaders: Readonly>; +} +export interface ClaudeCredentialStore { + read(): Promise; + write(raw: string): Promise; +} +export interface ClaudeAuth { + getCredentials(): Promise>; + forceRefresh(): Promise>; +} + +export function claudeCredentialLocation( + config: Pick, + env = process.env, + platform = process.platform, +) { + if (config.authFile) return { kind: "file" as const, path: expandHome(config.authFile) }; + const configured = config.configDir ?? env["CLAUDE_CONFIG_DIR"]; + const directory = configured === undefined ? join(homedir(), ".claude") : expandHome(configured); + if (platform !== "darwin") return { kind: "file" as const, path: join(directory, ".credentials.json") }; + const service = + "Claude Code-credentials" + + (configured === undefined ? "" : `-${createHash("sha256").update(resolve(directory)).digest("hex").slice(0, 8)}`); + return { kind: "keychain" as const, service }; +} + +interface StoreProcess { + stdin: Writable; + stderr: Readable; + kill(signal: "SIGKILL"): unknown; + once(event: "error", listener: () => void): unknown; + once(event: "close", listener: (code: number | null) => void): unknown; +} +export interface ClaudeStoreDeps { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + exec?: ( + file: string, + args: string[], + options: { timeout: number; maxBuffer: number }, + ) => Promise<{ stdout: string; stderr: string }>; + spawn?: (file: string, args: string[]) => StoreProcess; +} +const STORE_TIMEOUT_MS = 10_000; +const STORE_MAX_BYTES = 1024 * 1024; +const EXPIRY_SKEW_MS = 120_000; +const FORCED_REFRESH_COOLDOWN_MS = 30_000; + +export function createClaudeCredentialStore( + config: Pick, + deps: ClaudeStoreDeps = {}, +): ClaudeCredentialStore { + const location = claudeCredentialLocation(config, deps.env, deps.platform); + const execute = deps.exec ?? exec; + const start = + deps.spawn ?? ((file: string, args: string[]) => spawn(file, args, { stdio: ["pipe", "ignore", "pipe"] })); + if (location.kind === "file") + return { + read: () => readFile(location.path, "utf8"), + async write(raw) { + const temporary = `${location.path}.subswitch-${randomUUID()}.tmp`; + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(raw); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, location.path); + } finally { + await unlink(temporary).catch(() => undefined); + } + }, + }; + const read = async () => { + const output = ( + await execute("security", ["find-generic-password", "-s", location.service, "-w"], { + timeout: STORE_TIMEOUT_MS, + maxBuffer: STORE_MAX_BYTES, + }) + ).stdout.trimEnd(); + // security emits non-ASCII password data as bare hex. Native credential data is JSON. + return /^7b(?:[0-9a-f]{2})+$/i.test(output) ? Buffer.from(output, "hex").toString("utf8") : output; + }; + return { + read, + async write(raw) { + const metadata = await execute("security", ["find-generic-password", "-s", location.service], { + timeout: STORE_TIMEOUT_MS, + maxBuffer: STORE_MAX_BYTES, + }); + const account = /"acct"=(?:("(?:[^"\\]|\\.)*")|0x([0-9a-f]+))/i.exec(metadata.stdout + metadata.stderr); + if (!account) throw new Error("keychain_account_unavailable"); + const accountName: unknown = account[1] + ? JSON.parse(account[1]) + : Buffer.from(account[2]!, "hex").toString("utf8"); + if (typeof accountName !== "string" || /["\\\u0000-\u001f]/.test(accountName)) + throw new Error("keychain_account_unavailable"); + // Security's interactive command parser receives the secret on stdin, never argv. + // -X avoids the interactive parser's non-shell backslash/quote semantics. + const command = `add-generic-password -U -s "${location.service}" -a "${accountName}" -X ${Buffer.from(raw).toString("hex")}\n`; + await new Promise((resolve, reject) => { + const child = start("security", ["-i"]); + let bytes = 0, + failed = false; + const timer = setTimeout(() => { + failed = true; + child.kill("SIGKILL"); + }, STORE_TIMEOUT_MS); + child.stderr.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > STORE_MAX_BYTES) { + failed = true; + child.kill("SIGKILL"); + } + }); + child.stdin.on("error", () => { + failed = true; + }); + child.once("error", () => { + failed = true; + }); + child.once("close", (code) => { + clearTimeout(timer); + if (failed || code !== 0) reject(new Error("keychain_write_failed")); + else resolve(); + }); + child.stdin.end(command); + }); + // Interactive security can exit zero after a command failure. Verify the write. + if ((await read()) !== raw) throw new Error("keychain_write_failed"); + }, + }; +} + +interface RecordState { + root: Record; + oauth: Record; + access: string; + refresh?: string; + expires: number; +} +const parse = (raw: string): RecordState => { + const root = object(JSON.parse(raw)), + oauth = object(root?.["claudeAiOauth"]); + if ( + !root || + !oauth || + typeof oauth["accessToken"] !== "string" || + !oauth["accessToken"] || + typeof oauth["expiresAt"] !== "number" || + !Number.isFinite(oauth["expiresAt"]) + ) + throw new Error("invalid_claude_credentials"); + return { + root, + oauth, + access: oauth["accessToken"], + expires: oauth["expiresAt"], + ...(typeof oauth["refreshToken"] === "string" && oauth["refreshToken"] ? { refresh: oauth["refreshToken"] } : {}), + }; +}; +const credential = (record: RecordState): ClaudeCredential => ({ + provider: "claude", + authHeaders: { authorization: `Bearer ${record.access}` }, +}); + +/** Subscription only. Re-read native storage before refresh; serialize refreshes within this process. */ +export class ClaudeAuthManager implements ClaudeAuth { + private refresh: Promise> | undefined; + private lastForced = -Infinity; + private lastAccess: string | undefined; + constructor( + private readonly options: { + store: ClaudeCredentialStore; + oauthTokenUrl: string; + logger: Logger; + fetchImpl?: typeof fetch; + now?: () => number; + }, + ) {} + async getCredentials(): Promise> { + try { + const current = parse(await this.options.store.read()); + if (current.expires > this.now() + EXPIRY_SKEW_MS || (current.expires > this.now() && !current.refresh)) + return this.remember(current); + return this.refreshOnce(current); + } catch { + return err( + authError( + "Cannot read Claude subscription credentials. Sign in with Claude Code or unlock its credential store.", + ), + ); + } + } + async forceRefresh(): Promise> { + if (this.refresh) return this.refresh; + if (this.now() - this.lastForced < FORCED_REFRESH_COOLDOWN_MS) return this.getCredentials(); + this.lastForced = this.now(); + try { + const current = parse(await this.options.store.read()); + if (this.lastAccess && current.access !== this.lastAccess && current.expires > this.now()) + return this.remember(current); + return this.refreshOnce(current); + } catch { + return err(authError("Cannot read Claude subscription credentials. Sign in with Claude Code.")); + } + } + private now() { + return this.options.now?.() ?? Date.now(); + } + private remember(record: RecordState): Result { + this.lastAccess = record.access; + return ok(credential(record)); + } + private refreshOnce(initial: RecordState): Promise> { + if (this.refresh) return this.refresh; + this.refresh = this.performRefresh(initial).finally(() => { + this.refresh = undefined; + }); + return this.refresh; + } + private async performRefresh(initial: RecordState): Promise> { + try { + let current = parse(await this.options.store.read()); + if (current.access !== initial.access && current.expires > this.now()) return this.remember(current); + for (let attempt = 0; attempt < 2; attempt++) { + if (!current.refresh) + return err(authError("Claude subscription credentials cannot be refreshed. Sign in with Claude Code.")); + const response = await (this.options.fetchImpl ?? fetch)(this.options.oauthTokenUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(STORE_TIMEOUT_MS), + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: current.refresh, + client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + }), + }); + const raw = await readBoundedText(response.body, STORE_MAX_BYTES); + if (Buffer.byteLength(raw) > STORE_MAX_BYTES) throw new Error("refresh_body_limit"); + const next = object(JSON.parse(raw)); + const latest = parse(await this.options.store.read()); + if (!response.ok) { + if (latest.access !== current.access && latest.expires > this.now()) return this.remember(latest); + if (attempt === 0 && latest.refresh !== current.refresh && latest.refresh) { + current = latest; + continue; + } + this.options.logger.log("warn", CLAUDE_EVENTS.tokenRefreshFailed, { status: response.status }); + return err(authError("Claude subscription refresh failed. Sign in with Claude Code.")); + } + if ( + typeof next?.["access_token"] !== "string" || + !next["access_token"] || + typeof next["expires_in"] !== "number" || + !Number.isFinite(next["expires_in"]) || + next["expires_in"] <= 0 + ) + throw new Error("invalid_refresh_response"); + if (latest.access !== current.access && latest.expires > this.now()) return this.remember(latest); + const oauth = { + ...latest.oauth, + accessToken: next["access_token"], + refreshToken: typeof next["refresh_token"] === "string" ? next["refresh_token"] : current.refresh, + expiresAt: this.now() + next["expires_in"] * 1000, + }; + const written = JSON.stringify({ ...latest.root, claudeAiOauth: oauth }); + await this.options.store.write(written); + this.options.logger.log("info", CLAUDE_EVENTS.tokenRefreshed); + return this.remember(parse(written)); + } + return err(authError("Claude subscription refresh did not complete.")); + } catch { + this.options.logger.log("warn", CLAUDE_EVENTS.tokenRefreshFailed); + return err(authError("Claude subscription refresh or credential persistence failed. Sign in with Claude Code.")); + } + } +} + +export async function inspectClaudeAuth(store: ClaudeCredentialStore) { + try { + const record = parse(await store.read()); + return { available: true, expired: record.expires <= Date.now(), refreshable: !!record.refresh }; + } catch { + return { available: false, expired: false, refreshable: false }; + } +} diff --git a/src/claude-cache.ts b/src/claude-cache.ts new file mode 100644 index 0000000..ac6e097 --- /dev/null +++ b/src/claude-cache.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import type { ObjectValue as Item } from "./plain-object.js"; +import type { ClaudeReplay } from "./claude-state.js"; + +export interface Snapshot { + request: Item; + input: Item[]; +} +interface Values { + snapshot: Snapshot; + replay: ClaudeReplay; + adapted: true; +} +type Entry = { [K in keyof Values]: { kind: K; value: Values[K]; bytes: number } }[keyof Values]; + +/** One strict byte/entry budget covers snapshots, thinking replay, and adaptation markers. */ +export class ClaudeCache { + private readonly entries = new Map(); + private bytes = 0; + constructor(private readonly limits = { maxEntries: 4096, maxBytes: 64 * 1024 * 1024 }) { + assert(limits.maxEntries > 0 && limits.maxBytes > 0, "Claude cache requires positive bounds"); + } + put(kind: K, id: string, value: Values[K]): void { + const key = `${kind}:${id}`; + const bytes = Buffer.byteLength(JSON.stringify(value)) + Buffer.byteLength(key); + const old = this.entries.get(key); + if (old) { + this.bytes -= old.bytes; + this.entries.delete(key); + } + // Oversized entries must not silently expand the configured process budget. + if (bytes > this.limits.maxBytes) return; + this.entries.set(key, { kind, value, bytes } as Entry); + this.bytes += bytes; + while (this.entries.size > this.limits.maxEntries || this.bytes > this.limits.maxBytes) { + const oldest = this.entries.entries().next().value; + assert(oldest, "cache over budget without an entry to evict"); + this.bytes -= oldest[1].bytes; + this.entries.delete(oldest[0]); + } + } + get(kind: K, id: string): Values[K] | undefined { + const key = `${kind}:${id}`, + entry = this.entries.get(key); + if (!entry) return undefined; + assert(entry.kind === kind, "cache key and value kind disagree"); + this.entries.delete(key); + this.entries.set(key, entry); + // Only put writes this namespaced key, and the discriminant is checked above. + return entry.value as Values[K]; + } + get byteSize(): number { + return this.bytes; + } + get size(): number { + return this.entries.size; + } +} diff --git a/src/claude-contract.ts b/src/claude-contract.ts new file mode 100644 index 0000000..c60a82f --- /dev/null +++ b/src/claude-contract.ts @@ -0,0 +1,4 @@ +export { object, type ObjectValue } from "./plain-object.js"; + +/** Explicit subscription wire compatibility; never applied to the forward passthrough. */ +export const CLAUDE_SUBSCRIPTION_PREAMBLE = "You are Claude Code, Anthropic's official CLI for Claude."; diff --git a/src/claude-errors.ts b/src/claude-errors.ts new file mode 100644 index 0000000..72dc08e --- /dev/null +++ b/src/claude-errors.ts @@ -0,0 +1,116 @@ +/** Every translation failure has an explicit client status; new codes must choose one. */ +export const CLAUDE_ERROR_STATUS = { + namespace_collision: 400, + invalid_plaintext_call: 400, + claude_event_after_terminal: 502, + claude_response_too_large: 502, + claude_retry_bound: 502, + claude_stream_error: 502, + claude_thinking_state_unimplemented: 409, + concurrent_claude_stream_id: 409, + cross_provider_state_unavailable: 409, + discovery_response_too_large: 502, + duplicate_claude_start: 502, + duplicate_or_missing_tool_call_id: 502, + duplicate_tool_call: 400, + encrypted_tool_arguments: 400, + expected_object: 400, + expected_string: 400, + foreign_opaque_state: 409, + incomplete_claude_response: 502, + inconsistent_stop_reason: 502, + invalid_additional_tools: 400, + invalid_claude_block_index: 502, + invalid_claude_block_stop: 502, + invalid_claude_delta: 502, + invalid_claude_event: 502, + invalid_claude_message_delta: 502, + invalid_claude_response: 502, + invalid_claude_start: 502, + invalid_claude_tool_arguments: 502, + invalid_custom_tool_input: 502, + invalid_input_item: 400, + invalid_json_body: 400, + invalid_opaque_state: 409, + invalid_or_oversized_compressed_body: 413, + invalid_output_limit: 400, + invalid_previous_response_id: 400, + invalid_state_key: 500, + invalid_tool_arguments: 400, + invalid_tools: 400, + invalid_upstream_body: 502, + json_nesting_too_deep: 400, + mid_history_instructions_unimplemented: 400, + missing_claude_body: 502, + missing_claude_replay_state: 409, + missing_claude_start: 502, + missing_claude_terminal: 502, + missing_continuation_state: 409, + missing_tool_result: 400, + nontext_system: 400, + opaque_state_unimplemented: 409, + request_too_large: 413, + state_history_mismatch: 409, + structured_output_unimplemented: 400, + tool_definition_conflict: 400, + translated_compaction_unavailable: 400, + unknown_claude_tool: 502, + unknown_tool: 400, + unmatched_tool_result: 400, + unsupported_claude_delta: 502, + unsupported_claude_event: 502, + unsupported_claude_output: 502, + unsupported_content: 400, + unsupported_content_block: 400, + unsupported_content_encoding: 415, + unsupported_hosted_tool: 400, + unsupported_image: 400, + unsupported_input: 400, + unsupported_input_item: 400, + unsupported_message_role: 400, + unsupported_namespace: 400, + unsupported_reasoning_effort: 400, + unsupported_tool_choice: 400, + unsupported_tool_result_content: 400, + unterminated_claude_block: 502, + upstream_connection_failed: 502, + upstream_response_missing: 502, +} as const; +export type ClaudeErrorCode = keyof typeof CLAUDE_ERROR_STATUS; + +export class ReverseContractError extends Error { + constructor(readonly code: ClaudeErrorCode) { + super(code); + } +} + +export class ClaudeHttpError extends Error { + constructor( + readonly status: number, + message: string, + readonly code: string, + readonly retryAfter?: string, + ) { + super(message); + } +} + +const STATE_MESSAGES: Partial> = { + missing_claude_replay_state: "Claude continuation state is no longer available in this SubSwitch process. Start a new conversation.", + missing_continuation_state: "This response's continuation state is no longer available. Start a new conversation.", + invalid_opaque_state: "Claude continuation state is invalid or belongs to a previous SubSwitch process. Start a new conversation.", +}; + +export const claudeFailure = (error: unknown) => { + if (error instanceof ClaudeHttpError) + return { status: error.status, message: error.message, code: error.code, retryAfter: error.retryAfter }; + if (error instanceof Error && "code" in error && error.code === "ETIMEDOUT") + return { status: 504, message: "OpenAI connection timed out.", code: "openai_timeout", retryAfter: undefined }; + const code = error instanceof ReverseContractError ? error.code : "upstream_connection_failed"; + return { + status: CLAUDE_ERROR_STATUS[code], + code, + message: STATE_MESSAGES[code] ?? `SubSwitch could not translate this request (${code}).`, + retryAfter: undefined, + }; +}; diff --git a/src/claude-handler.ts b/src/claude-handler.ts new file mode 100644 index 0000000..f92d5b6 --- /dev/null +++ b/src/claude-handler.ts @@ -0,0 +1,162 @@ +import { CLAUDE_EVENTS } from "./provider-events.js"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { randomUUID } from "node:crypto"; +import { createSseParser, type SseEvent } from "./codex-response.js"; +import { reverseRequest, ReverseContractError, type Item } from "./claude-adapter.js"; +import { translateClaudeStream } from "./claude-stream.js"; +import { ReverseState } from "./claude-state.js"; +import { CLAUDE_SUBSCRIPTION_PREAMBLE, object } from "./claude-contract.js"; +import type { ClaudeProviderConfig } from "./config.js"; +import type { ClaudeAuth } from "./claude-auth.js"; +import type { Logger } from "./logger.js"; +import { readBoundedText } from "./provider-transport.js"; +import { redactCredentials } from "./errors.js"; +import { SUBSWITCH_NAME, SUBSWITCH_VERSION } from "./version.js"; + +import { ClaudeHttpError } from "./claude-errors.js"; +export { ClaudeHttpError } from "./claude-errors.js"; + +export class ClaudeHandler { + private readonly state: ReverseState; + constructor( + private readonly config: ClaudeProviderConfig, + private readonly auth: ClaudeAuth, + private readonly logger: Logger, + private readonly fetchImpl: typeof fetch = fetch, + state?: ReverseState, + ) { + this.state = state ?? new ReverseState(undefined, config.reasoningCache); + } + + private async authorizedFetch(body: Item, signal: AbortSignal, model: string, trace: { sessionKey?: string }) { + let credentials = await this.auth.getCredentials(); + signal.throwIfAborted(); + // These are the relay's Claude credentials. A 401 would make native Codex + // refresh its unrelated OpenAI login instead of displaying the setup problem. + if (!credentials.ok) throw new ClaudeHttpError(503, credentials.error.message, "claude_auth_unavailable"); + let response: Response | undefined; + for (let attempt = 0; attempt < 2; attempt++) { + const headers = { + ...credentials.value.authHeaders, + "content-type": "application/json", + accept: "text/event-stream", + "anthropic-version": "2023-06-01", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20", + "user-agent": `${SUBSWITCH_NAME}/${SUBSWITCH_VERSION}`, + }; + response = await this.fetchImpl(`${this.config.baseUrl.replace(/\/$/, "")}/v1/messages`, { + method: "POST", + headers, + body: JSON.stringify({ ...body, stream: true }), + redirect: "error", + signal, + }); + if (response.status !== 401 || attempt === 1) break; + await response.body?.cancel(); + this.logger.log("info", CLAUDE_EVENTS.upstream401Refreshing, { model, ...trace }); + credentials = await this.auth.forceRefresh(); + signal.throwIfAborted(); + if (!credentials.ok) throw new ClaudeHttpError(503, credentials.error.message, "claude_auth_unavailable"); + } + if (!response) throw new ReverseContractError("claude_retry_bound"); + return { response, credentials }; + } + + async *respond(request: Item, externalSignal: AbortSignal, sessionKey?: string): AsyncGenerator { + const controller = new AbortController(); + const signal = AbortSignal.any([externalSignal, controller.signal]); + const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); + let idle: ReturnType | undefined; + let source: Readable | undefined; + let terminal = false; + const started = Date.now(), + model = String(request["model"]); + const trace = sessionKey === undefined ? {} : { sessionKey }; + try { + const translated = reverseRequest( + { ...request, max_output_tokens: request["max_output_tokens"] ?? 64000 }, + [{ type: "text", text: CLAUDE_SUBSCRIPTION_PREAMBLE }], + this.state, + ); + const { response, credentials } = await this.authorizedFetch(translated.body, signal, model, trace); + if (!response.ok) { + let message = "Claude rejected the request.", + code = "claude_upstream_error"; + const raw = await readBoundedText(response.body, this.config.maxSseEventBytes); + try { + const error = object(object(JSON.parse(raw))?.["error"]); + if (typeof error?.["message"] === "string") message = error["message"]; + if (typeof error?.["type"] === "string" && /^[a-z_]{1,80}$/.test(error["type"])) code = error["type"]; + } catch { + /* Never return upstream HTML or parser exceptions. */ + } + const token = credentials.ok + ? credentials.value.authHeaders["authorization"]?.replace(/^Bearer /, "") + : undefined; + message = redactCredentials(token ? message.replaceAll(token, "") : message); + this.logger.log("warn", CLAUDE_EVENTS.upstreamError, { + model, + ...trace, + status: response.status, + errorCode: code, + }); + throw new ClaudeHttpError(response.status, message, code, response.headers.get("retry-after") ?? undefined); + } + if (!response.body) throw new ReverseContractError("missing_claude_body"); + source = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream); + const parser = createSseParser(this.config.maxSseEventBytes); + const resetIdle = () => { + clearTimeout(idle); + idle = setTimeout(() => controller.abort(), this.config.streamIdleTimeoutMs); + }; + source.on("data", resetIdle); + resetIdle(); + const running = pipeline(source, parser, { signal }).catch((error) => { + parser.destroy(error as Error); + }); + try { + for await (const event of translateClaudeStream(parser as AsyncIterable, { + id: `resp_subswitch_${randomUUID()}`, + model, + request: translated, + state: this.state, + maxBytes: this.config.maxAggregateBytes, + })) { + if ( + event["type"] === "response.output_item.done" && + ["function_call", "custom_tool_call"].includes(String(object(event["item"])?.["type"])) + ) + this.logger.log("info", CLAUDE_EVENTS.toolCall, { model, ...trace }); + terminal = event["type"] === "response.completed" || event["type"] === "response.incomplete"; + if (terminal) { + const usage = object(object(event["response"])?.["usage"]); + const cached = object(usage?.["input_tokens_details"])?.["cached_tokens"]; + this.logger.log("info", CLAUDE_EVENTS.requestComplete, { + model, + ...trace, + status: 200, + latencyMs: Date.now() - started, + ...(typeof cached === "number" ? { cachedTokens: cached } : {}), + }); + } + yield event; + if (terminal) break; + } + await running; + } finally { + parser.destroy(); + await running; + } + } catch (error) { + if (terminal && signal.aborted) return; + if (signal.aborted && !externalSignal.aborted) + throw new ClaudeHttpError(504, "Claude request timed out.", "claude_timeout"); + throw error; + } finally { + clearTimeout(timeout); + clearTimeout(idle); + source?.destroy(); + } + } +} diff --git a/src/claude-models.ts b/src/claude-models.ts new file mode 100644 index 0000000..e208355 --- /dev/null +++ b/src/claude-models.ts @@ -0,0 +1,119 @@ +/** The destination registry for Codex ingress. Forward-ingress reservation rules stay independent. */ +import { isPlainObject } from "./plain-object.js"; +import { compareGen } from "./models.js"; +export interface ClaudeModel { + readonly id: string; + readonly family: string; + readonly gen: readonly number[]; + readonly contextWindow: number; + readonly maxOutputTokens: number; +} + +export const CLAUDE_MODELS: readonly ClaudeModel[] = [ + { id: "claude-sonnet-5", family: "sonnet", gen: [5], contextWindow: 1_000_000, maxOutputTokens: 128_000 }, + { id: "claude-opus-5", family: "opus", gen: [5], contextWindow: 1_000_000, maxOutputTokens: 128_000 }, + { id: "claude-fable-5", family: "fable", gen: [5], contextWindow: 1_000_000, maxOutputTokens: 128_000 }, + { id: "claude-fable-5-1", family: "fable", gen: [5, 1], contextWindow: 1_000_000, maxOutputTokens: 128_000 }, +]; + +export const isOpenaiModelName = (name: string): boolean => + /^(gpt-|o[134](?:-|$)|codex:|sol(?:$|\[)|terra(?:$|\[)|luna(?:$|\[))/i.test(name); + +export const validClaudeAlias = (name: string, target: string): boolean => + !isOpenaiModelName(name) && !isOpenaiModelName(target) && target.startsWith("claude-"); + +export function claudeResolver(aliases: Readonly>) { + const names = new Map(); + const rejectedAliases: string[] = []; + const families = new Map(); + for (const model of CLAUDE_MODELS) { + names.set(model.id, model.id); + names.set(`claude:${model.id}`, model.id); + const current = families.get(model.family); + if (!current || compareGen(model.gen, current.gen) > 0) families.set(model.family, model); + } + for (const model of families.values()) { + names.set(model.family, model.id); + names.set(`claude:${model.family}`, model.id); + } + for (const [name, target] of Object.entries(aliases)) { + if (!validClaudeAlias(name, target)) { + rejectedAliases.push(name); + continue; + } + names.set(name, target); + names.set(target, target); + names.set(`claude:${name}`, target); + } + // Canonical IDs retain precedence over aliases, matching the forward resolver. + for (const id of [ + ...CLAUDE_MODELS.map((model) => model.id), + ...Object.entries(aliases) + .filter(([name, target]) => validClaudeAlias(name, target)) + .map(([, target]) => target), + ]) { + names.set(id, id); + names.set(`claude:${id}`, id); + } + return Object.assign((name: string): string | undefined => names.get(name), { rejectedAliases }); +} + +export function claudeModelRows(aliases: Readonly>) { + const resolve = claudeResolver(aliases); + const ids = new Set([...CLAUDE_MODELS.map((model) => model.id), ...Object.values(aliases)]); + return [...ids].map((id) => ({ + id, + provider: "claude", + registered: CLAUDE_MODELS.some((model) => model.id === id), + aliases: [ + ...new Set([ + ...CLAUDE_MODELS.filter((model) => model.id === id && resolve(model.family) === id).map( + (model) => model.family, + ), + ...Object.entries(aliases) + .filter(([, target]) => target === id) + .map(([name]) => name), + ]), + ], + })); +} + +/** Preserve OpenAI's evolving catalog and derive only the native tool-surface fields for Claude. */ +export function augmentCodexModels( + body: Record, + aliases: Readonly>, +): Record { + const models = Array.isArray(body["models"]) && body["models"].every(isPlainObject) ? body["models"] : undefined; + if (!models?.length) return body; + const template = models.find((model) => model["tool_mode"] === "code_mode_only") ?? models[0]!; + const present = new Set(models.map((model) => model["slug"])); + const additions = claudeModelRows(aliases).flatMap((row) => { + const capability = CLAUDE_MODELS.find((model) => model.id === row.id); + if (!capability) return []; // Custom aliases route, but unverified capabilities are not advertised. + return [row.id, ...row.aliases] + .filter((slug) => !present.has(slug)) + .map((slug) => ({ + ...template, + slug, + display_name: slug, + description: `Claude via SubSwitch (${row.id})`, + supported_in_api: true, + context_window: capability.contextWindow, + max_context_window: capability.contextWindow, + default_reasoning_level: "high", + supported_reasoning_levels: ["low", "medium", "high", "xhigh", "max"].map((effort) => ({ + effort, + description: effort, + })), + model_messages: null, + base_instructions: + "You are a coding assistant running in Codex. Follow the user's task and the native tool definitions.", + supports_search_tool: false, + input_modalities: ["text"], + additional_speed_tiers: [], + service_tiers: [], + default_service_tier: null, + })); + }); + return { ...body, models: [...models, ...additions] }; +} diff --git a/src/claude-state.ts b/src/claude-state.ts new file mode 100644 index 0000000..302ad58 --- /dev/null +++ b/src/claude-state.ts @@ -0,0 +1,117 @@ +/** Authenticated process-local Claude thinking state. No persistent credential or key writes. */ +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; +import { object } from "./claude-contract.js"; +import { ReverseContractError, type Item } from "./claude-adapter.js"; +import { ClaudeCache } from "./claude-cache.js"; + +export interface ClaudeReplay { + content: Item[]; + output: Item[]; +} +export class ReverseState { + private readonly references: ClaudeCache; + constructor( + private readonly key: Buffer = randomBytes(32), + limits = { maxEntries: 4096, maxBytes: 64 * 1024 * 1024 }, + cache?: ClaudeCache, + ) { + if (key.length !== 32) throw new ReverseContractError("invalid_state_key"); + this.references = cache ?? new ClaudeCache(limits); + } + seal(value: ClaudeReplay): string { + return this.encrypt({ version: 1, provider: "claude", ...value }); + } + /** Issue a stable handle before text, then attach opaque content only at a valid terminal. */ + begin(): { token: string; commit(value: ClaudeReplay): void } { + const reference = randomUUID(); + // Native Codex retains this handle after cancellation. Until completion it has + // no opaque content to replay; any readable partial assistant text stays in + // the client's ordinary history. Never replay unfinished thinking or tools. + // This placeholder shares the LRU budget: eviction/restart still fails closed. + this.references.put("replay", reference, { content: [], output: [] }); + return { + token: this.encrypt({ version: 1, provider: "claude", reference }), + commit: (value) => this.references.put("replay", reference, value), + }; + } + private encrypt(value: Item): string { + const iv = randomBytes(12), + cipher = createCipheriv("aes-256-gcm", this.key, iv); + cipher.setAAD(Buffer.from("subswitch:claude-replay:v1")); + const bytes = Buffer.from(JSON.stringify(value)); + const encrypted = Buffer.concat([cipher.update(bytes), cipher.final()]); + return `subswitch-claude-v1.${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64url")}`; + } + open(token: string): ClaudeReplay { + if (!token.startsWith("subswitch-claude-v1.")) throw new ReverseContractError("foreign_opaque_state"); + try { + const bytes = Buffer.from(token.slice("subswitch-claude-v1.".length), "base64url"); + if (bytes.length < 29 || bytes.length > 4 * 1024 * 1024) throw new Error(); + const decipher = createDecipheriv("aes-256-gcm", this.key, bytes.subarray(0, 12)); + decipher.setAAD(Buffer.from("subswitch:claude-replay:v1")); + decipher.setAuthTag(bytes.subarray(12, 28)); + const parsed = object( + JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8")), + ); + if (parsed?.["version"] !== 1 || parsed["provider"] !== "claude") throw new Error(); + if (typeof parsed["reference"] === "string") { + const value = this.references.get("replay", parsed["reference"]); + if (!value) throw new ReverseContractError("missing_claude_replay_state"); + return value; + } + if ( + !Array.isArray(parsed["content"]) || + !parsed["content"].every((value) => object(value)) || + !Array.isArray(parsed["output"]) || + !parsed["output"].every((value) => object(value)) + ) + throw new Error(); + return { content: parsed["content"] as Item[], output: parsed["output"] as Item[] }; + } catch (error) { + if (error instanceof ReverseContractError) throw error; + throw new ReverseContractError("invalid_opaque_state"); + } + } +} + +/** Bind state to meaningful native call/message fields; clients may omit status and item IDs. */ +export function replayIdentity(entry: Item): string { + const type = entry["type"] ?? (entry["role"] ? "message" : undefined); + if (type === "message") { + const content = entry["content"]; + const blocks = + typeof content === "string" + ? [{ type: "text", text: content }] + : Array.isArray(content) + ? content.map((value) => { + const block = object(value); + return block && ["input_text", "output_text", "text"].includes(String(block["type"])) + ? { type: "text", text: block["text"] } + : value; + }) + : content; + return JSON.stringify([type, entry["role"], blocks]); + } + let args = entry["arguments"] ?? entry["input"]; + if (type === "function_call" && typeof args === "string") { + try { + args = canonicalObject(JSON.parse(args)); + } catch (error) { + if (error instanceof ReverseContractError) throw error; + } + } + return JSON.stringify([type, entry["call_id"], entry["namespace"] ?? null, entry["name"], args]); +} + +function canonicalObject(value: unknown, depth = 0): unknown { + if (depth > 128) throw new ReverseContractError("json_nesting_too_deep"); + if (Array.isArray(value)) return value.map((entry) => canonicalObject(entry, depth + 1)); + const obj = object(value); + return obj + ? Object.fromEntries( + Object.keys(obj) + .sort() + .map((key) => [key, canonicalObject(obj[key], depth + 1)]), + ) + : value; +} diff --git a/src/claude-stream.ts b/src/claude-stream.ts new file mode 100644 index 0000000..bb422e3 --- /dev/null +++ b/src/claude-stream.ts @@ -0,0 +1,252 @@ +import { randomUUID } from "node:crypto"; +import { object } from "./claude-contract.js"; +import { + reverseResponse, + reverseEvents, + ReverseContractError, + type Item, + type ReverseRequest, +} from "./claude-adapter.js"; +import type { ReverseState } from "./claude-state.js"; +import type { SseEvent } from "./codex-response.js"; + +interface BlockState { + block: Item; + open: boolean; + arguments: string; + streamed: boolean; + id?: string; + outputIndex?: number; +} +type StreamPhase = "awaiting_start" | "streaming" | "finished"; +interface StreamOptions { + id: string; + model: string; + request: ReverseRequest; + state: ReverseState; + maxBytes: number; +} + +/** Incremental text with authenticated thinking replay; executable calls commit only at message_stop. */ +export async function* translateClaudeStream( + source: AsyncIterable, + options: StreamOptions, +): AsyncGenerator { + const { id, model } = options; + const reasoningId = `rs_${randomUUID()}`; + const replay = options.state.begin(); + const base = { + id, + object: "response", + created_at: Math.floor(Date.now() / 1000), + model, + status: "in_progress", + output: [], + }; + let sequence = 0, + size = 0, + nextOutput = 1; + let phase: StreamPhase = "awaiting_start"; + let stopReason: unknown, + usage: Item = {}; + const blocks: BlockState[] = []; + let toolSeen = false; + const frame = (event: Item): Item => ({ ...event, sequence_number: sequence++ }); + function fail(code: import("./claude-errors.js").ClaudeErrorCode): never { + throw new ReverseContractError(code); + } + for await (const incoming of source) { + size += Buffer.byteLength(incoming.data); + if (size > options.maxBytes) fail("claude_response_too_large"); + let event: Item | undefined; + try { + event = object(JSON.parse(incoming.data)); + } catch { + fail("invalid_claude_event"); + } + if (!event || typeof event["type"] !== "string") fail("invalid_claude_event"); + if (event["type"] === "ping") continue; + if (phase === "finished") fail("claude_event_after_terminal"); + if (event["type"] === "error") fail("claude_stream_error"); + if (event["type"] === "message_start") { + if (phase !== "awaiting_start") fail("duplicate_claude_start"); + const message = object(event["message"]); + if (message?.["type"] !== "message") fail("invalid_claude_start"); + phase = "streaming"; + usage = object(message["usage"]) ?? {}; + yield frame({ type: "response.created", response: base }); + yield frame({ type: "response.in_progress", response: base }); + const reasoning = { type: "reasoning", id: reasoningId, summary: [], encrypted_content: replay.token }; + yield frame({ type: "response.output_item.added", output_index: 0, item: reasoning }); + yield frame({ type: "response.output_item.done", output_index: 0, item: reasoning }); + continue; + } + if (phase !== "streaming") fail("missing_claude_start"); + if (event["type"] === "content_block_start") { + const index = event["index"], + block = object(event["content_block"]); + if (typeof index !== "number" || index !== blocks.length || !block) fail("invalid_claude_block_index"); + if (!["text", "tool_use", "thinking", "redacted_thinking"].includes(String(block["type"]))) + fail("unsupported_claude_output"); + const state: BlockState = { block: { ...block }, open: true, arguments: "", streamed: false }; + blocks.push(state); + if (block["type"] === "tool_use") { + toolSeen = true; + if (typeof block["name"] !== "string" || !options.request.tools.has(block["name"])) fail("unknown_claude_tool"); + } + if (block["type"] === "text" || block["type"] === "tool_use") { + const itemId = `${block["type"] === "text" ? "msg" : "fc"}_${randomUUID()}`; + state.id = itemId; + state.outputIndex = nextOutput++; + if (block["type"] === "text" && !toolSeen) { + state.streamed = true; + const output_index = state.outputIndex; + yield frame({ + type: "response.output_item.added", + output_index, + item: { + type: "message", + id: itemId, + role: "assistant", + status: "in_progress", + phase: "final_answer", + content: [], + }, + }); + yield frame({ + type: "response.content_part.added", + output_index, + item_id: itemId, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }); + if (typeof block["text"] === "string" && block["text"]) + yield frame({ + type: "response.output_text.delta", + output_index, + item_id: itemId, + content_index: 0, + delta: block["text"], + }); + } + } + } else if (event["type"] === "content_block_delta") { + const index = event["index"], + delta = object(event["delta"]); + if (typeof index !== "number" || !blocks[index]?.open || !delta) fail("invalid_claude_delta"); + const state = blocks[index]!; + const block = state.block; + if (delta["type"] === "text_delta" && block["type"] === "text" && typeof delta["text"] === "string") { + block["text"] = String(block["text"] ?? "") + delta["text"]; + if (state.streamed) + yield frame({ + type: "response.output_text.delta", + item_id: state.id, + output_index: state.outputIndex, + content_index: 0, + delta: delta["text"], + }); + } else if ( + delta["type"] === "input_json_delta" && + block["type"] === "tool_use" && + typeof delta["partial_json"] === "string" + ) { + state.arguments += delta["partial_json"]; + } else if ( + delta["type"] === "thinking_delta" && + block["type"] === "thinking" && + typeof delta["thinking"] === "string" + ) { + block["thinking"] = String(block["thinking"] ?? "") + delta["thinking"]; + } else if ( + delta["type"] === "signature_delta" && + block["type"] === "thinking" && + typeof delta["signature"] === "string" + ) { + block["signature"] = String(block["signature"] ?? "") + delta["signature"]; + } else fail("unsupported_claude_delta"); + } else if (event["type"] === "content_block_stop") { + const index = event["index"]; + if (typeof index !== "number" || !blocks[index]?.open) fail("invalid_claude_block_stop"); + blocks[index]!.open = false; + const partial = blocks[index]!.arguments; + if (partial) { + try { + blocks[index]!.block["input"] = object(JSON.parse(partial)) ?? fail("invalid_claude_tool_arguments"); + } catch { + fail("invalid_claude_tool_arguments"); + } + } + } else if (event["type"] === "message_delta") { + const delta = object(event["delta"]); + if (!delta) fail("invalid_claude_message_delta"); + if (delta["stop_reason"] !== undefined && delta["stop_reason"] !== null) stopReason = delta["stop_reason"]; + usage = { ...usage, ...object(event["usage"]) }; + } else if (event["type"] === "message_stop") { + phase = "finished"; + for (const event of emitTerminal(options, blocks, stopReason, usage, reasoningId, replay)) yield frame(event); + } else fail("unsupported_claude_event"); + } + if (phase !== "finished") fail("missing_claude_terminal"); +} + +/** Validate a complete message before committing replay state or executable tool calls. */ +function* emitTerminal( + options: StreamOptions, + blocks: BlockState[], + stopReason: unknown, + usage: Item, + reasoningId: string, + replay: ReturnType, +): Generator { + const fail = (code: import("./claude-errors.js").ClaudeErrorCode): never => { + throw new ReverseContractError(code); + }; + if (blocks.some((block) => block.open)) fail("unterminated_claude_block"); + const incomplete = stopReason === "max_tokens"; + const content = blocks.map((entry) => entry.block); + const identities = new Map(blocks.flatMap((entry, index) => (entry.id ? [[index, entry.id] as const] : []))); + const streamedText = new Set(blocks.filter((entry) => entry.streamed).map((entry) => entry.id)); + const response = { + type: "message", + stop_reason: incomplete || stopReason === "refusal" ? "end_turn" : stopReason, + content: incomplete ? content.filter((block) => block["type"] === "text") : content, + }; + const selectedIdentities = incomplete + ? new Map( + content + .map((block, index) => ({ block, id: identities.get(index) })) + .filter((entry) => entry.block["type"] === "text") + .map((entry, index) => [index, entry.id!]), + ) + : identities; + const output = reverseResponse(response, options.request, options.state, selectedIdentities, replay.token); + output[0]!["id"] = reasoningId; + replay.commit({ content: response.content, output: output.slice(1) }); + const complete = reverseEvents(options.id, options.model, output, usage); + for (const event of complete) { + const type = event["type"]; + if (["response.created", "response.in_progress"].includes(String(type))) continue; + if (type === "response.output_item.done" && object(event["item"])?.["type"] === "reasoning") continue; + if ( + type === "response.output_item.added" && + (object(event["item"])?.["type"] === "reasoning" || streamedText.has(String(object(event["item"])?.["id"]))) + ) + continue; + if ( + (type === "response.content_part.added" || type === "response.output_text.delta") && + streamedText.has(String(event["item_id"])) + ) + continue; + if (type === "response.completed" && incomplete) { + yield { + type: "response.incomplete", + response: { + ...object(event["response"]), + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + }, + }; + } else yield event; + } +} diff --git a/src/cli.ts b/src/cli.ts index ab65520..5815f32 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { CLIENT_IDS, parseClientSelection, selectedClients, type ClientId, type ClientSelection as Client } from "./clients.js"; import { readFile } from "node:fs/promises"; import { parseArgs } from "node:util"; import { createColors } from "picocolors"; @@ -18,6 +19,7 @@ import { import { MODEL_REGISTRY, formatModelsReport, buildModelRows, routableModelCount, PROVIDER_IDS } from "./models.js"; import { resolveColorEnabled } from "./tty.js"; import { SUBSWITCH_NAME, SUBSWITCH_VERSION } from "./version.js"; +import { claudeModelRows } from "./claude-models.js"; const SHUTDOWN_GRACE_MS = 5000; @@ -40,14 +42,14 @@ const fail = (message: string): void => { // --------------------------------------------------------------------------- const USAGE = `\ -subswitch — local subscription-routing proxy for Claude Code +subswitch — local subscription-routing proxy for Claude Code and Codex Usage: subswitch [command] [flags] Commands: serve Start the proxy (default command) - doctor Check config, codex auth, and network reachability - init Interactive setup — writes config + wires Claude Code + doctor Check config, subscription auth, and network reachability + init Interactive setup — wires the selected client(s) models Show effective alias table (registry × aliases) --json Output model registry as JSON (no color, no TTY check) @@ -67,6 +69,11 @@ Flags (init): --settings-target "local" (.claude/settings.local.json, default) or "shared" (.claude/settings.json) +Flags (init, doctor, models): + --client "claude-code", "codex", or "all" + init/models default to claude-code; doctor defaults to all + all selects supported clients; both is a compatibility alias + Examples: subswitch serve # start proxy on port ${DEFAULT_PORT} subswitch serve --port 8080 # start proxy on a custom port @@ -90,16 +97,16 @@ type CliCommand = | { readonly kind: "help" } | { readonly kind: "version" } | { readonly kind: "serve"; readonly verbose: boolean; readonly quiet: boolean; readonly port?: string } - | { readonly kind: "doctor" } - | { readonly kind: "models"; readonly json: boolean } - | { readonly kind: "init"; readonly yes: boolean; readonly dryRun: boolean; readonly flags: InitFlags }; + | { readonly kind: "doctor"; readonly client: Client } + | { readonly kind: "models"; readonly json: boolean; readonly client: Client } + | { readonly kind: "init"; readonly yes: boolean; readonly dryRun: boolean; readonly flags: InitFlags; readonly client: Client }; // Flag sets per command — used for per-command validation (A3.19) const GLOBAL_FLAGS = new Set(["help", "version"]); const SERVE_FLAGS = new Set(["verbose", "quiet", "port"]); -const DOCTOR_FLAGS = new Set(); -const MODELS_FLAGS = new Set(["json"]); -const INIT_FLAGS = new Set(["yes", "dry-run", "port", "settings-target"]); +const DOCTOR_FLAGS = new Set(["client"]); +const MODELS_FLAGS = new Set(["json", "client"]); +const INIT_FLAGS = new Set(["yes", "dry-run", "port", "settings-target", "client"]); /** * Pure: parse process.argv slice into a typed CliCommand. @@ -123,6 +130,7 @@ const parseCliArgs = (argv: string[]): { ok: true; value: CliCommand } | { ok: f "settings-target": { type: "string" }, // models flags json: { type: "boolean" }, + client: { type: "string" }, }, allowPositionals: true, strict: true, @@ -136,6 +144,8 @@ const parseCliArgs = (argv: string[]): { ok: true; value: CliCommand } | { ok: f if (values.version === true) return { ok: true, value: { kind: "version" } }; const command = positionals[0] ?? "serve"; + const client = parseClientSelection(values.client ?? (command === "doctor" ? "all" : "claude-code")); + if (!client) return { ok: false, error: { message: `client must be ${CLIENT_IDS.join(", ")}, or all` } }; // Unknown command if (command !== "serve" && command !== "doctor" && command !== "init" && command !== "models") { @@ -178,6 +188,7 @@ const parseCliArgs = (argv: string[]): { ok: true; value: CliCommand } | { ok: f ok: true, value: { kind: "init", + client, yes: values.yes === true, dryRun: values["dry-run"] === true, flags: { @@ -203,11 +214,11 @@ const parseCliArgs = (argv: string[]): { ok: true; value: CliCommand } | { ok: f } if (command === "doctor") { - return { ok: true, value: { kind: "doctor" } }; + return { ok: true, value: { kind: "doctor", client } }; } // command === "models" - return { ok: true, value: { kind: "models", json: values.json === true } }; + return { ok: true, value: { kind: "models", json: values.json === true, client } }; } catch (e) { // Translate parseArgs throw to Result err (A3.21) if (e instanceof Error) { @@ -298,6 +309,10 @@ const serve = async ( const hostSuffix = host !== "" ? ` → ${host}` : ""; errOut(` ${id.padEnd(8)} ${modelCount} model${modelCount === 1 ? "" : "s"}${hostSuffix}`); } + if (effectiveConfig.codexIngress.enabled) { + errOut(effectiveConfig.codexIngress.claude.enabled ? " codex ingress OpenAI passthrough + Claude model routing (HTTP/WebSocket)" : + " codex ingress native OpenAI passthrough (HTTP/WebSocket); Claude translation unavailable"); + } errOut(` run \`subswitch doctor\` to verify setup\n`); const shutdown = (): void => { @@ -314,15 +329,15 @@ const serve = async ( // doctor // --------------------------------------------------------------------------- -const doctor = async (result: LoadConfigResult): Promise => { +const doctor = async (result: LoadConfigResult, client: Client): Promise => { const color = resolveColorEnabled( process.env as Record, process.stdout.isTTY === true, ); - process.exitCode = await runDoctor( + process.exitCode = client === "codex" ? 0 : await runDoctor( result.config, - result.configPath, + result.configPaths.length ? result.configPaths.join(" + ") : result.configPath, result.fileFound, { write: out, @@ -333,9 +348,13 @@ const doctor = async (result: LoadConfigResult): Promise => { listAgentFiles: makeLiveListAgentFiles(), readTextFile: makeLiveReadTextFile(), }, - // Only providers the user wrote into the config file can fail the exit code. (avoids PF-006) + // Only providers explicitly present in the loaded configuration sources can fail the exit code. (avoids PF-006) result.configuredProviders, ); + if (client === "codex" || (client === "all" && result.config.codexIngress.claude.enabled)) { + const { runCodexDoctor } = await import("./codex-doctor.js"); + process.exitCode = Math.max(Number(process.exitCode ?? 0), await runCodexDoctor(result.config, out, { color })); + } }; // --------------------------------------------------------------------------- @@ -350,7 +369,7 @@ const doctor = async (result: LoadConfigResult): Promise => { * * Never writes credentials, tokens, PII, or secrets. [compliance] */ -const modelsJson = (result: LoadConfigResult): void => { +const modelsJson = (result: LoadConfigResult, client: Client): void => { const { config, configPath, fileFound } = result; const rows = buildModelRows(MODEL_REGISTRY, aliasesByProvider(config)); @@ -374,10 +393,24 @@ const modelsJson = (result: LoadConfigResult): void => { models: rows, }; - out(JSON.stringify(payload)); + const reverse = { kind: "models", schemaVersion: 2, client: "codex", subswitchVersion: SUBSWITCH_VERSION, + fallbackProvider: "codex", enabled: config.codexIngress.enabled && config.codexIngress.claude.enabled, + models: claudeModelRows(config.codexIngress.claude.aliases) }; + const catalogs = { "claude-code": payload, codex: reverse } satisfies Record; + if (client === "all") { + out(JSON.stringify({ kind: "models", schemaVersion: 2, client: "all", clients: catalogs })); + return; + } + out(JSON.stringify(catalogs[client])); }; -const models = (config: Config): void => { +const models = (config: Config, client: Client): void => { + if (client === "all") { for (const id of selectedClients(client)) models(config, id); return; } + if (client === "codex") { + out(`subswitch models — Codex → Claude (${config.codexIngress.enabled && config.codexIngress.claude.enabled ? "enabled" : "disabled"})`); + for (const row of claudeModelRows(config.codexIngress.claude.aliases)) out(` ${row.id}${row.aliases.length ? ` ${row.aliases.join(", ")}` : ""}`); + return; + } const color = resolveColorEnabled( process.env as Record, process.stdout.isTTY === true, @@ -416,6 +449,24 @@ const runInit = async (command: Extract): Promise< const fsDeps = makeRealFsDeps(); const env = process.env as Record; + if (command.client !== "claude-code") { + const decision = resolveInitDispatch(process.stdin.isTTY === true, process.stdout.isTTY === true, "CI" in env, command.yes); + if (!command.dryRun && decision === "refuse") { fail("no interactive terminal detected. Re-run with --yes, or preview with --dry-run."); return; } + let port = command.flags.port; + if (!command.dryRun && decision === "interactive") { + const prompts = await makeClackPrompts(); prompts.intro("SubSwitch setup"); + const selected = await prompts.text({ message: "Proxy port", initialValue: port ?? String(DEFAULT_PORT), validate: value => PortSchema.safeParse(value).success ? undefined : "Use a port between 1 and 65535" }); + if (prompts.isCancel(selected)) { prompts.cancel("Setup cancelled"); process.exitCode = 1; return; } + port = String(selected); + } + const { runCodexInit } = await import("./codex-init.js"); + const result = await runCodexInit({ client: command.client, dryRun: command.dryRun, + ...(port === undefined ? {} : { port }), ...(command.flags.settingsTarget === undefined ? {} : { settingsTarget: command.flags.settingsTarget }), + }, fsDeps, env, projectDir, out); + if (!result.ok) fail(result.error.message); + return; + } + // --dry-run: always use non-interactive planning path; no TTY check required // because it writes nothing — the fail-closed contract only protects writes. [F34] if (command.dryRun) { @@ -426,7 +477,7 @@ const runInit = async (command: Extract): Promise< const decision = resolveInitDispatch( process.stdin.isTTY === true, process.stdout.isTTY === true, - "CI" in process.env, + "CI" in env, command.yes, ); @@ -486,7 +537,7 @@ const main = async (): Promise => { fail(configResult.error.message); return; } - await doctor(configResult.value); + await doctor(configResult.value, command.client); return; } @@ -498,10 +549,10 @@ const main = async (): Promise => { } // JSON branch returns before resolveColorEnabled — FORCE_COLOR cannot bleed into JSON. [7b] if (command.json) { - modelsJson(configResult.value); + modelsJson(configResult.value, command.client); return; } - models(configResult.value.config); + models(configResult.value.config, command.client); return; } diff --git a/src/clients.ts b/src/clients.ts new file mode 100644 index 0000000..01418e2 --- /dev/null +++ b/src/clients.ts @@ -0,0 +1,12 @@ +/** Native clients supported by this build; providers and model names are a separate registry. */ +export const CLIENT_IDS = ["claude-code", "codex"] as const; +export type ClientId = (typeof CLIENT_IDS)[number]; +export type ClientSelection = ClientId | "all"; + +export const parseClientSelection = (value: string): ClientSelection | undefined => { + if (value === "all" || value === "both") return "all"; + return CLIENT_IDS.find(client => client === value); +}; + +export const selectedClients = (selection: ClientSelection): readonly ClientId[] => + selection === "all" ? CLIENT_IDS : [selection]; diff --git a/src/codex-body.ts b/src/codex-body.ts new file mode 100644 index 0000000..e0b7d30 --- /dev/null +++ b/src/codex-body.ts @@ -0,0 +1,22 @@ +import { object, type ObjectValue as Item } from "./plain-object.js"; +import { ReverseContractError } from "./claude-errors.js"; + +export const json = (raw: Buffer): Item => { + try { + return object(JSON.parse(raw.toString("utf8"))) ?? invalid(); + } catch { + return invalid(); + } +}; +function invalid(): never { + throw new ReverseContractError("invalid_json_body"); +} +const invalidInput = (): never => { + throw new ReverseContractError("invalid_input_item"); +}; +export const inputItems = (input: unknown): Item[] => + Array.isArray(input) + ? input.map((value) => object(value) ?? invalidInput()) + : typeof input === "string" + ? [{ type: "message", role: "user", content: input }] + : []; diff --git a/src/codex-doctor.ts b/src/codex-doctor.ts new file mode 100644 index 0000000..e30d0e6 --- /dev/null +++ b/src/codex-doctor.ts @@ -0,0 +1,175 @@ +import { createColors } from "picocolors"; +import { CodexIngressHealthSchema } from "./codex-health.js"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { parseTOML, getStaticTOMLValue } from "toml-eslint-parser"; +import { object } from "./claude-contract.js"; +import { CLAUDE_MODELS, claudeResolver, claudeModelRows } from "./claude-models.js"; +import { createClaudeCredentialStore, inspectClaudeAuth } from "./claude-auth.js"; +import { expandHome, isLoopbackHost, type Config } from "./config.js"; +import { doctorRow, makeLiveHttpGet, makeLiveTlsConnect, type HttpGetResult, type TlsStatus } from "./doctor.js"; +import { inspectAuthFile } from "./codex-auth.js"; + +export async function runCodexDoctor( + config: Config, + write: (line: string) => void, + options: { + env?: Record; + project?: string; + color?: boolean; + read?: (path: string) => Promise; + auth?: () => Promise<{ available: boolean; expired: boolean; refreshable: boolean }>; + httpGet?: (url: string) => Promise; + tlsConnect?: (host: string, port: number) => Promise; + } = {}, +): Promise { + const env = options.env ?? process.env; + const read = + options.read ?? + (async (path: string) => { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + }); + const pc = createColors(options.color ?? false); + let failures = 0; + const report = (label: string, success: boolean, detail: string) => { + write(doctorRow(`${label}:`, `${success ? pc.green("OK") : pc.red("FAIL")} — ${detail}`)); + if (!success) failures++; + }; + write("subswitch doctor — Codex → Claude"); + report( + "routing", + config.codexIngress.enabled && config.codexIngress.claude.enabled, + config.codexIngress.claude.enabled ? "Claude model routing enabled" : "run subswitch init --client codex", + ); + const auth = await ( + options.auth ?? (() => inspectClaudeAuth(createClaudeCredentialStore(config.codexIngress.claude))) + )().catch(() => ({ available: false, expired: false, refreshable: false })); + report( + "Claude subscription", + auth.available && (!auth.expired || auth.refreshable), + !auth.available + ? "sign in with Claude Code or unlock its store" + : auth.expired + ? "expired; refresh required" + : "available", + ); + const health = await (options.httpGet ?? makeLiveHttpGet())( + `http://127.0.0.1:${config.port}/__subswitch/health`, + ).catch(() => ({ ok: false as const, connectionRefused: false })); + let running = false; + if (health.ok && health.status === 200) { + try { + const parsed = CodexIngressHealthSchema.safeParse(object(JSON.parse(health.body))?.["codexIngress"]); + running = parsed.success && parsed.data.enabled && parsed.data.translationAvailable; + } catch {} + } + report( + "proxy", + running, + running ? `listening on port ${config.port}` : "start subswitch serve with this configuration", + ); + const endpoint = new URL(config.codexIngress.claude.baseUrl); + if (endpoint.protocol === "https:") { + const tls = await (options.tlsConnect ?? makeLiveTlsConnect())( + endpoint.hostname, + Number(endpoint.port || "443"), + ).catch(() => ({ kind: "unreachable" as const })); + report("Claude connectivity", tls.kind === "reachable", endpoint.hostname); + } + const resolve = claudeResolver(config.codexIngress.claude.aliases); + for (const row of claudeModelRows(config.codexIngress.claude.aliases)) { + write(` model: ${row.id}${row.aliases.length ? ` (${row.aliases.join(", ")})` : ""}`); + if (!row.registered) + report( + "alias target", + false, + "custom model capabilities are not in the registry; automatic native discovery is unavailable for this target", + ); + } + const globalPath = join(env["CODEX_HOME"] ?? join(homedir(), ".codex"), "config.toml"); + const projectPath = join(options.project ?? process.cwd(), ".codex", "config.toml"); + for (const path of [...new Set([globalPath, projectPath])]) + await checkNativeConfig(path, globalPath, config, read, resolve, report, write); + write(failures ? pc.red(`${failures} check(s) failed`) : pc.green("all checks passed")); + return failures ? 1 : 0; +} + +const checkNativeConfig = async ( + path: string, + globalPath: string, + config: Config, + read: (path: string) => Promise, + resolve: (model: string) => string | undefined, + report: (label: string, success: boolean, detail: string) => void, + write: (line: string) => void, +): Promise => { + try { + const source = await read(path); + if (source === null) { + if (path === globalPath) report("Codex setup", false, "native configuration is missing; run init --client codex"); + return; + } + const root = object(getStaticTOMLValue(parseTOML(source, { tomlVersion: "1.0.0" }))) ?? {}; + if (path === globalPath) { + let matches = false; + let subscription = false; + if (typeof root["openai_base_url"] === "string") { + try { + const url = new URL(root["openai_base_url"]); + matches = + isLoopbackHost(url.hostname) && + url.port === String(config.port) && + /^\/codex\/(?:v1|backend-api\/codex)\/?$/.test(url.pathname); + subscription = url.pathname.includes("/backend-api/codex"); + } catch {} + } + report( + "Codex endpoint", + matches && (root["model_provider"] === undefined || root["model_provider"] === "openai"), + matches ? "points at SubSwitch" : "run init --client codex or configure the native endpoint", + ); + if (matches && subscription) { + const raw = await read(config.providers.codex.authFile); + const inspection = raw === null ? undefined : inspectAuthFile(raw); + report( + "Codex subscription", + inspection?.ok === true, + inspection?.ok + ? "configured credential store available" + : "sign in with Codex and select its matching authFile", + ); + } + } + for (const [name, value] of Object.entries(object(root["agents"]) ?? {})) { + try { + const role = object(value); + if (!role) continue; + let model = role["model"]; + if (typeof role["config_file"] === "string") { + const file = expandHome(role["config_file"]); + const source = await read(isAbsolute(file) ? file : join(dirname(path), file)); + if (source === null) { + report(`agent ${name}`, false, "role configuration file is missing"); + continue; + } + model = object(getStaticTOMLValue(parseTOML(source, { tomlVersion: "1.0.0" })))?.["model"]; + } + if (typeof model !== "string") continue; + const destination = resolve(model); + if (destination) write(` agent ${name}: ${model} → ${destination}`); + else if (model.startsWith("claude-") || CLAUDE_MODELS.some((entry) => entry.family === model)) + report(`agent ${name}`, false, "Claude model is not in the registry or configured aliases"); + } catch { + report(`agent ${name}`, false, "cannot read or parse role configuration"); + } + } + } catch { + report("Codex configuration", false, `cannot parse or read ${path}`); + } +}; diff --git a/src/codex-gateway.ts b/src/codex-gateway.ts new file mode 100644 index 0000000..0eee155 --- /dev/null +++ b/src/codex-gateway.ts @@ -0,0 +1,366 @@ +import { CodexWebSockets } from "./codex-ws.js"; +import { CodexUpstream } from "./codex-upstream.js"; +import { json, inputItems } from "./codex-body.js"; +import { decideCodexRoute, type ClaudeResolution } from "./codex-route.js"; +import { CLAUDE_EVENTS, OPENAI_EVENTS } from "./provider-events.js"; +import { WebSocketBudget } from "./websocket-budget.js"; +import { CodexNativeAuth } from "./codex-native-auth.js"; +import { type IncomingMessage, type ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; +import { randomUUID, createHash } from "node:crypto"; +import { decodeBody as decode } from "./content-encoding.js"; +import { claudeFailure } from "./claude-errors.js"; +import type { Config } from "./config.js"; +import type { Logger } from "./logger.js"; +import { createOpenaiPassthrough, type OpenaiPassthrough, type CodexIngressEntry } from "./openai-passthrough.js"; +import { type ForwardedBody } from "./raw-http-passthrough.js"; +import { sniffLeadingModel, MODEL_SNIFF_BYTES } from "./anthropic-parse.js"; +import { namespaceRequest } from "./collaboration-compat.js"; +import { object } from "./claude-contract.js"; +import { type ClaudeAuth } from "./claude-auth.js"; +import { ClaudeHandler, ClaudeHttpError } from "./claude-handler.js"; +import { reverseEvents, ReverseContractError, type Item } from "./claude-adapter.js"; +import { ClaudeCache, type Snapshot } from "./claude-cache.js"; +import { ReverseState } from "./claude-state.js"; +import { createFrameWriter, drainRejectedUpload } from "./provider-transport.js"; +import { SYNTHESIZED_HEADER, SYNTHESIZED_MARKER, openaiErrorBody, openaiFailureEvent } from "./errors.js"; +import type { CodexEndpointMode } from "./codex-ingress.js"; +import type { ProviderAuth } from "./provider-auth.js"; + +/** Reverse-enabled ingress: raw same-provider forwarding, scoped collaboration adaptation, Claude dispatch. */ +export class CodexGateway implements CodexIngressEntry { + private readonly raw: OpenaiPassthrough; + private readonly upstream: CodexUpstream; + private readonly webSockets: CodexWebSockets; + private readonly claude: ClaudeHandler; + private readonly resolve: (model: string) => string | undefined; + private readonly cache: ClaudeCache; + private readonly controllers = new Set(); + private closed = false; + private readonly streams = new WeakMap(); + private readonly config: Config; + private readonly logger: Logger; + private readonly nativeAuth: CodexNativeAuth; + constructor(options: { + config: Config; + logger: Logger; + claudeAuth: ClaudeAuth; + resolveClaude: (model: string) => string | undefined; + parentAuth?: ProviderAuth<"codex">; + fetchImpl?: typeof fetch; + }) { + const { config, logger, claudeAuth, fetchImpl, parentAuth } = options; + this.config = config; + this.logger = logger; + this.nativeAuth = new CodexNativeAuth(parentAuth); + const provider = config.codexIngress.claude; + const budget = new WebSocketBudget(config.codexIngress.maxUpstreamSockets); + this.raw = createOpenaiPassthrough(config.codexIngress, logger, budget); + this.resolve = options.resolveClaude; + this.cache = new ClaudeCache(provider.reasoningCache); + this.upstream = new CodexUpstream({ + config, + logger, + raw: this.raw, + nativeAuth: this.nativeAuth, + cache: this.cache, + closed: () => this.closed, + onError: (res, error) => this.httpError(res, error), + }); + this.webSockets = new CodexWebSockets({ + config, + logger, + raw: this.raw, + nativeAuth: this.nativeAuth, + budget, + controllers: this.controllers, + destination: (body) => this.destination(body), + parentRequest: (body) => this.parentRequest(body), + events: (body, model, signal, session) => this.claudeEvents(body, model, signal, session), + correlation: (req) => this.correlation(req), + }); + this.claude = new ClaudeHandler( + provider, + claudeAuth, + logger, + fetchImpl, + new ReverseState(undefined, provider.reasoningCache, this.cache), + ); + } + close(): void { + this.closed = true; + for (const controller of this.controllers) controller.abort(); + this.webSockets.close(); + this.raw.close(); + } + http(req: IncomingMessage, res: ServerResponse, mode: CodexEndpointMode, path: string): void { + if (this.closed) { + this.httpError(res, new ClaudeHttpError(503, "SubSwitch is shutting down.", "proxy_closing")); + return; + } + void this.handleHttp(req, res, mode, path).catch((error) => this.httpError(res, error)); + } + private httpError(res: ServerResponse, error: unknown): void { + if (res.destroyed || res.writableEnded) return; + const failure = claudeFailure(error); + this.logger.log("warn", CLAUDE_EVENTS.requestFailed, { status: failure.status, errorCode: failure.code }); + if (res.headersSent) { + const stream = this.streams.get(res); + res.end( + `data: ${JSON.stringify( + openaiFailureEvent( + failure.message, + failure.code, + stream ?? { + id: `resp_subswitch_${randomUUID()}`, + model: "", + sequence: 0, + }, + ), + )}\n\n`, + ); + this.streams.delete(res); + } else { + res.writeHead(failure.status, { + "content-type": "application/json", + [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER, + ...(failure.retryAfter ? { "retry-after": failure.retryAfter } : {}), + }); + res.end(openaiErrorBody(failure.message, failure.code)); + } + } + private async readBody(req: IncomingMessage): Promise { + const parts: Buffer[] = []; + let bytes = 0; + for await (const chunk of req.iterator({ destroyOnReturn: false })) { + bytes += chunk.length; + parts.push(chunk); + if (bytes > this.config.limits.maxBufferedBodyBytes) return { kind: "prefix", bytes: Buffer.concat(parts) }; + } + return { kind: "complete", bytes: Buffer.concat(parts) }; + } + private fullRequest(body: Item, model: string): Item { + const previous = body["previous_response_id"]; + if (previous !== undefined && previous !== null && typeof previous !== "string") + throw new ReverseContractError("invalid_previous_response_id"); + let snapshot: Snapshot | undefined; + if (typeof previous === "string") { + snapshot = this.cache.get("snapshot", previous); + if (!snapshot) throw new ReverseContractError("missing_continuation_state"); + if (snapshot.request["model"] !== model) throw new ReverseContractError("cross_provider_state_unavailable"); + } + const full: Item = { + ...snapshot?.request, + ...body, + model, + input: [...(snapshot?.input ?? []), ...inputItems(body["input"])], + }; + delete full["previous_response_id"]; + if (body["generate"] !== false) delete full["generate"]; + return full; + } + private destination(body: Item): ClaudeResolution { + if (typeof body["model"] === "string") { + const model = this.resolve(body["model"]); + return model ? { kind: "claude", model } : { kind: "foreign" }; + } + if (typeof body["previous_response_id"] === "string") { + const snapshot = this.cache.get("snapshot", body["previous_response_id"]); + if (typeof snapshot?.request["model"] === "string") return this.destination(snapshot.request); + } + return { kind: "absent" }; + } + private correlation(req: IncomingMessage): string | undefined { + const value = req.headers["thread-id"] ?? req.headers["session-id"]; + return typeof value === "string" && value.length <= 1024 + ? createHash("sha256").update(value).digest("hex").slice(0, 8) + : undefined; + } + private async *claudeEvents( + body: Item, + model: string, + signal: AbortSignal, + sessionKey?: string, + ): AsyncGenerator { + const full = this.fullRequest(body, model); + if (body["generate"] === false) { + const id = `resp_subswitch_${randomUUID()}`; + this.cache.put("snapshot", id, { request: full, input: inputItems(full["input"]) }); + yield* reverseEvents(id, model, []); + return; + } + if ( + inputItems(body["input"]).some((entry) => + ["function_call_output", "custom_tool_call_output"].includes(String(entry["type"])), + ) + ) + this.logger.log("info", CLAUDE_EVENTS.toolResult, { model }); + for await (const event of this.claude.respond(full, signal, sessionKey)) { + if (event["type"] === "response.completed") { + const response = object(event["response"]); + if (typeof response?.["id"] === "string" && Array.isArray(response["output"])) + this.cache.put("snapshot", response["id"], { + request: full, + input: [...inputItems(full["input"]), ...inputItems(response["output"])], + }); + } + yield event; + } + } + private parentRequest(body: Item): Item { + if (typeof body["previous_response_id"] === "string" && body["previous_response_id"].startsWith("resp_subswitch_")) + throw new ReverseContractError("cross_provider_state_unavailable"); + return namespaceRequest(body); + } + private async handleHttp( + req: IncomingMessage, + res: ServerResponse, + mode: CodexEndpointMode, + path: string, + ): Promise { + if (!this.config.codexIngress.claude.enabled) { + this.raw.http(req, res, mode, path, undefined, await this.nativeAuth.headers(req, mode, path)); + return; + } + const pathname = path.split("?")[0]; + if (req.method === "GET" && pathname === "/models") { + await this.upstream.http( + req, + res, + mode, + path, + undefined, + "models", + await this.nativeAuth.headers(req, mode, path), + ); + return; + } + if (req.method !== "POST" || (pathname !== "/responses" && pathname !== "/responses/compact")) { + this.raw.http(req, res, mode, path, undefined, await this.nativeAuth.headers(req, mode, path)); + return; + } + const consumed = await this.readBody(req); + if (this.closed) throw new ClaudeHttpError(503, "SubSwitch is shutting down.", "proxy_closing"); + const raw = consumed.bytes; + if (consumed.kind === "prefix") { + const name = sniffLeadingModel(raw.subarray(0, MODEL_SNIFF_BYTES)); + if (name && this.resolve(name)) { + drainRejectedUpload(req); + throw new ReverseContractError("request_too_large"); + } + const headers = await this.nativeAuth.headers(req, mode, path); + this.logger.log("warn", OPENAI_EVENTS.compatOverWindowPassthrough, { bodyMode: "streamed" }); + this.raw.http(req, res, mode, path, req.readableEnded ? { kind: "complete", bytes: raw } : consumed, headers); + return; + } + let body: Item; + try { + body = json(await decode(raw, req.headers["content-encoding"], this.config.limits.maxBufferedBodyBytes)); + } catch { + // An uninspectable request remains the original provider's responsibility. + this.raw.http( + req, + res, + mode, + path, + { kind: "complete", bytes: raw }, + await this.nativeAuth.headers(req, mode, path), + ); + return; + } + const route = decideCodexRoute(pathname ?? "", this.destination(body)); + switch (route.kind) { + case "parent": { + const nativeHeaders = await this.nativeAuth.headers(req, mode, path); + if (pathname === "/responses/compact") { + this.forwardNative(req, res, mode, path, raw, nativeHeaders); + return; + } + const mapped = this.parentRequest(body); + const continuationAdapted = + typeof body["previous_response_id"] === "string" && !!this.cache.get("adapted", body["previous_response_id"]); + if (!continuationAdapted && mapped === body) { + this.forwardNative(req, res, mode, path, raw, nativeHeaders); + } else + await this.upstream.http( + req, + res, + mode, + path, + Buffer.from(JSON.stringify(mapped)), + body["stream"] === true ? "namespace-stream" : "namespace", + nativeHeaders, + ); + return; + } + case "rejected": + throw new ReverseContractError(route.code); + case "claude": + return this.streamClaude(req, res, body, route.model); + default: { + const exhaustive: never = route; + return exhaustive; + } + } + } + private forwardNative( + req: IncomingMessage, + res: ServerResponse, + mode: CodexEndpointMode, + path: string, + body: Buffer, + headers: readonly string[], + ): void { + if (this.nativeAuth.canRefresh(req, mode, path)) this.upstream.http(req, res, mode, path, body, "raw", headers); + else this.raw.http(req, res, mode, path, { kind: "complete", bytes: body }, headers); + } + private async streamClaude(req: IncomingMessage, res: ServerResponse, body: Item, model: string): Promise { + const controller = new AbortController(); + this.controllers.add(controller); + const close = () => { + if (!res.writableFinished) controller.abort(); + }; + res.on("close", close); + const write = createFrameWriter(res, controller.signal); + let result: unknown; + let ping: ReturnType | undefined; + try { + for await (const event of this.claudeEvents(body, model, controller.signal, this.correlation(req))) { + if (body["stream"] === true) { + const response = object(event["response"]); + const previous = this.streams.get(res); + this.streams.set(res, { + id: typeof response?.["id"] === "string" ? response["id"] : (previous?.id ?? ""), + model, + sequence: + typeof event["sequence_number"] === "number" ? event["sequence_number"] : (previous?.sequence ?? 0), + }); + if (!res.headersSent) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER, + }); + ping = setInterval(() => { + if (!res.destroyed && res.writableLength < 65536) res.write(": ping\n\n"); + }, this.config.limits.pingIntervalMs); + } + await write(`data: ${JSON.stringify(event)}\n\n`); + } else if (event["type"] === "response.completed" || event["type"] === "response.incomplete") + result = event["response"]; + } + if (body["stream"] !== true) { + res.writeHead(200, { "content-type": "application/json", [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER }); + res.end(JSON.stringify(result)); + } else res.end(); + } finally { + clearInterval(ping); + res.off("close", close); + this.controllers.delete(controller); + if (res.writableEnded || res.destroyed) this.streams.delete(res); + } + } + upgrade(req: IncomingMessage, socket: Duplex, head: Buffer, mode: CodexEndpointMode, path: string): void { + this.webSockets.upgrade(req, socket, head, mode, path); + } +} diff --git a/src/codex-health.ts b/src/codex-health.ts new file mode 100644 index 0000000..0ded81b --- /dev/null +++ b/src/codex-health.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import type { Config } from "./config.js"; +import { CLAUDE_MODELS } from "./claude-models.js"; + +export const CodexIngressHealthSchema = z.object({ + schemaVersion: z.literal(1), + enabled: z.boolean(), + mode: z.enum(["model-routing", "passthrough"]), + translationAvailable: z.boolean(), + credentials: z.literal("client"), + transports: z.array(z.enum(["http", "websocket"])), + subscriptionAuth: z.literal("native-store").optional(), + claudeModelCount: z.number().int().nonnegative().optional(), +}); +export type CodexIngressHealth = z.infer; + +export const codexIngressHealth = (config: Config): CodexIngressHealth | undefined => { + if (!config.codexIngress.enabled) return undefined; + const enabled = config.codexIngress.claude.enabled; + return { + schemaVersion: 1, + enabled: true, + mode: enabled ? "model-routing" : "passthrough", + translationAvailable: enabled, + credentials: "client", + transports: ["http", "websocket"], + ...(enabled ? { subscriptionAuth: "native-store", claudeModelCount: CLAUDE_MODELS.length } : {}), + }; +}; diff --git a/src/codex-ingress.ts b/src/codex-ingress.ts new file mode 100644 index 0000000..36f013c --- /dev/null +++ b/src/codex-ingress.ts @@ -0,0 +1,26 @@ +/** Source protocol/endpoint selection is independent of destination model resolution. */ +export type CodexEndpointMode = "subscription" | "api"; + +const BASES = { + subscription: "/codex/backend-api/codex", + api: "/codex/v1", +} as const; + +export type CodexIngressRoute = + | { readonly kind: "other" } + | { readonly kind: "reserved" } + | { readonly kind: "codex"; readonly mode: CodexEndpointMode; readonly path: string }; + +/** Never normalize paths or parse query strings: untouched traffic keeps its wire target. */ +export function codexIngressRoute(rawPath: string): CodexIngressRoute { + const pathname = rawPath.split("?", 1)[0] ?? rawPath; + if (pathname !== "/codex" && !pathname.startsWith("/codex/")) return { kind: "other" }; + for (const mode of ["subscription", "api"] as const) { + const base = BASES[mode]; + if (pathname === base || pathname.startsWith(`${base}/`)) { + const suffix = rawPath.slice(base.length); + return { kind: "codex", mode, path: suffix }; + } + } + return { kind: "reserved" }; +} diff --git a/src/codex-init.ts b/src/codex-init.ts new file mode 100644 index 0000000..d9271ca --- /dev/null +++ b/src/codex-init.ts @@ -0,0 +1,273 @@ +import { ok, err, type Result } from "./result.js"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseTOML, getStaticTOMLValue } from "toml-eslint-parser"; +import { hasNativeDecoders } from "./content-encoding.js"; +import { userConfigPath, expandHome, loadConfig, isLoopbackHost } from "./config.js"; +import { object } from "./claude-contract.js"; +import { + planConfigWrite, + planSettingsWrite, + PortSchema, + type InitFsDeps, + type SettingsTarget, + type InitError, +} from "./init.js"; + +const invalidSetup = (message: string): Result => err({ kind: "invalid_input", message }); + +export interface SetupWrite { + readonly path: string; + readonly content: string; + readonly preview: string; +} +export interface CodexSetupPaths { + readonly codexConfig: string; + readonly subswitchConfig: string; + readonly project: string; +} + +export function codexSetupPaths(env: Record, project: string): CodexSetupPaths { + return { + codexConfig: join(env["CODEX_HOME"] ?? join(homedir(), ".codex"), "config.toml"), + subswitchConfig: env["SUBSWITCH_CONFIG"] ? expandHome(env["SUBSWITCH_CONFIG"]) : userConfigPath(env), + project, + }; +} + +/** Edit only the root endpoint value, preserving comments, multiline strings, and native settings. */ +export function planCodexEndpoint( + source: string, + port: number, + mode: "subscription" | "api" = "subscription", +): Result<{ content: string; endpoint: string; previous: string | undefined }, InitError> { + let ast: ReturnType; + try { + ast = parseTOML(source, { tomlVersion: "1.0.0" }); + } catch { + return invalidSetup("Cannot parse Codex config.toml. Fix the TOML before running init."); + } + const root = object(getStaticTOMLValue(ast)); + if (!root) return invalidSetup("Codex configuration must be a TOML document."); + if (root["model_provider"] !== undefined && root["model_provider"] !== "openai") + return invalidSetup( + "Codex uses a custom model_provider. Configure its endpoint manually; init will not replace it.", + ); + const previous = root["openai_base_url"]; + if (previous !== undefined && typeof previous !== "string") + return invalidSetup("Codex openai_base_url must be a string."); + const endpoint = `http://127.0.0.1:${port}/codex/${mode === "subscription" ? "backend-api/codex" : "v1"}`; + const field = ast.body[0].body.find( + (node) => + node.type === "TOMLKeyValue" && + getStaticTOMLValue(node.key).length === 1 && + getStaticTOMLValue(node.key)[0] === "openai_base_url", + ); + let content: string; + if (field?.type === "TOMLKeyValue") + content = source.slice(0, field.value.range[0]) + JSON.stringify(endpoint) + source.slice(field.value.range[1]); + else content = `openai_base_url = ${JSON.stringify(endpoint)}${source.includes("\r\n") ? "\r\n" : "\n"}${source}`; + return ok({ content, endpoint, previous }); +} + +export async function planCodexSetup( + options: { + client: "codex" | "all"; + port: number; + settingsTarget: SettingsTarget; + mode?: "subscription" | "api"; + codexAuthFile?: string; + }, + paths: CodexSetupPaths, + fs: InitFsDeps, +): Promise> { + try { + const source = (await fs.readFile(paths.codexConfig)) ?? ""; + const endpoint = planCodexEndpoint(source, options.port, options.mode); + if (!endpoint.ok) return endpoint; + const native = endpoint.value; + const existing = await fs.readFile(paths.subswitchConfig); + let global: Record | undefined; + try { + global = existing === null ? {} : object(JSON.parse(existing)); + } catch { + return invalidSetup("Cannot parse SubSwitch configuration JSON."); + } + if (!global) return invalidSetup("SubSwitch configuration must be a JSON object."); + const ingress = object(global["codexIngress"]) ?? {}; + const claude = object(ingress["claude"]) ?? {}; + const nextIngress: Record = { ...ingress, enabled: true, claude: { ...claude, enabled: true } }; + if (native.previous) { + let previous: URL; + try { + previous = new URL(native.previous); + } catch { + return invalidSetup("Existing Codex endpoint is not a valid URL. Configure SubSwitch manually."); + } + const owned = + isLoopbackHost(previous.hostname) && /^\/codex\/(?:v1|backend-api\/codex)\/?$/.test(previous.pathname); + if (!owned) { + if ( + (previous.protocol !== "https:" && !(previous.protocol === "http:" && isLoopbackHost(previous.hostname))) || + previous.username || + previous.password || + previous.search || + previous.hash + ) + return invalidSetup("Existing Codex endpoint is not a safe HTTP(S) upstream. Configure SubSwitch manually."); + nextIngress[options.mode === "api" ? "apiBaseUrl" : "subscriptionBaseUrl"] = native.previous; + const expectedHost = options.mode === "api" ? "api.openai.com" : "chatgpt.com"; + if ( + !isLoopbackHost(previous.hostname) && + (previous.hostname !== expectedHost || (previous.port && previous.port !== "443")) && + ingress["allowInsecureBaseUrl"] !== true + ) + return invalidSetup( + `Existing Codex endpoint uses '${previous.host}'. Set codexIngress.allowInsecureBaseUrl to true in ${paths.subswitchConfig} only if you trust this upstream, then rerun init.`, + ); + } + } + const providers = object(global["providers"]) ?? {}; + const codex = object(providers["codex"]) ?? {}; + const configContent = + JSON.stringify( + { + ...global, + port: options.port, + codexIngress: nextIngress, + ...(options.codexAuthFile && codex["authFile"] === undefined + ? { providers: { ...providers, codex: { ...codex, authFile: options.codexAuthFile } } } + : {}), + }, + null, + 2, + ) + "\n"; + const checked = loadConfig({ configPath: paths.subswitchConfig, env: {}, readFile: () => configContent }); + if (!checked.ok) return invalidSetup(checked.error.message); + const writes: SetupWrite[] = [ + { path: paths.subswitchConfig, content: configContent, preview: configContent.trimEnd() }, + ]; + let forwardSettings: SetupWrite | undefined; + if (options.client === "all") { + const projectConfigPath = join(paths.project, "subswitch.config.json"); + const sameConfig = resolve(projectConfigPath) === resolve(paths.subswitchConfig); + const projectConfig = planConfigWrite( + sameConfig ? configContent : await fs.readFile(projectConfigPath), + options.port, + paths.project, + ); + if (!projectConfig.ok) return projectConfig; + if (!sameConfig) { + const project = object(JSON.parse(projectConfig.value.content))!; + const projectIngress = object(project["codexIngress"]) ?? {}; + const content = + JSON.stringify( + { + ...project, + codexIngress: { + ...projectIngress, + enabled: true, + claude: { ...object(projectIngress["claude"]), enabled: true }, + }, + }, + null, + 2, + ) + "\n"; + writes.push({ path: projectConfigPath, content, preview: content.trimEnd() }); + } + const settingsPath = join( + paths.project, + ".claude", + options.settingsTarget === "shared" ? "settings.json" : "settings.local.json", + ); + const settings = planSettingsWrite( + await fs.readFile(settingsPath), + options.port, + options.settingsTarget, + paths.project, + ); + if (!settings.ok) return settings; + forwardSettings = { ...settings.value, preview: `env.ANTHROPIC_BASE_URL = http://127.0.0.1:${options.port}` }; + } + writes.push({ + path: paths.codexConfig, + content: native.content, + preview: `openai_base_url = ${JSON.stringify(native.endpoint)} (other settings preserved)`, + }); + if (forwardSettings) writes.push(forwardSettings); + return ok(writes); + } catch { + return invalidSetup("Cannot read configuration while planning Codex setup."); + } +} + +export async function runCodexInit( + options: { + client: "codex" | "all"; + port?: string; + settingsTarget?: string; + dryRun: boolean; + }, + fs: InitFsDeps, + env: Record, + project: string, + write: (line: string) => void, +): Promise> { + try { + if (!hasNativeDecoders()) + return invalidSetup("Codex → Claude setup requires Node 22.15 or newer for native zstd support."); + const parsedPort = PortSchema.safeParse(options.port ?? 4141); + if (!parsedPort.success) return invalidSetup("port must be between 1 and 65535"); + const port = parsedPort.data; + if ( + options.settingsTarget !== undefined && + options.settingsTarget !== "local" && + options.settingsTarget !== "shared" + ) + return invalidSetup("settings-target must be local or shared."); + const paths = codexSetupPaths(env, project); + let mode: "subscription" | "api" = "subscription"; + const auth = await fs.readFile(join(env["CODEX_HOME"] ?? join(homedir(), ".codex"), "auth.json")); + if (auth) { + try { + const value = object(JSON.parse(auth)); + if (value?.["auth_mode"] === "apikey" || (!value?.["tokens"] && typeof value?.["OPENAI_API_KEY"] === "string")) + mode = "api"; + } catch { + /* Setup does not repair or replace credential stores; doctor diagnoses them. */ + } + } else write("Claude and Codex must be signed in before inference; run doctor after setup."); + const plans = await planCodexSetup( + { + client: options.client, + port, + settingsTarget: options.settingsTarget ?? "local", + mode, + ...(env["CODEX_HOME"] ? { codexAuthFile: join(env["CODEX_HOME"], "auth.json") } : {}), + }, + paths, + fs, + ); + if (!plans.ok) return plans; + for (const plan of plans.value) { + if (options.dryRun) { + write(`[dry-run] Would update ${plan.path}:`); + write(plan.preview); + } else { + await fs.writeFile(plan.path, plan.content); + write(`Written: ${plan.path}`); + } + } + write( + options.dryRun + ? "[dry-run] No files written." + : `Next: run subswitch serve, then subswitch doctor --client ${options.client}. Native agents can use model sonnet, opus, or fable.`, + ); + return ok(undefined); + } catch { + return err({ + kind: "write_error", + message: "Cannot read or write Codex setup files. Check their permissions and rerun init.", + }); + } +} diff --git a/src/codex-native-auth.ts b/src/codex-native-auth.ts new file mode 100644 index 0000000..7f04855 --- /dev/null +++ b/src/codex-native-auth.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage } from "node:http"; +import type { ProviderAuth } from "./provider-auth.js"; +import type { CodexEndpointMode } from "./codex-ingress.js"; +import { ClaudeHttpError } from "./claude-errors.js"; + +const NATIVE_ENDPOINTS = new Set(["/responses", "/responses/compact", "/models"]); + +/** Operator credentials are available only on exact native endpoint paths. */ +export class CodexNativeAuth { + constructor(private readonly auth?: ProviderAuth<"codex">) {} + canRefresh(req: IncomingMessage, mode: CodexEndpointMode, path: string): boolean { + return ( + mode === "subscription" && + NATIVE_ENDPOINTS.has(path.split("?")[0] ?? "") && + req.headers.authorization === undefined && + typeof req.headers["chatgpt-account-id"] === "string" && + this.auth !== undefined + ); + } + async headers( + req: IncomingMessage, + mode: CodexEndpointMode, + path: string, + refresh = false, + ): Promise { + if (!this.canRefresh(req, mode, path) || !this.auth) return req.rawHeaders; + const credentials = await (refresh ? this.auth.forceRefresh() : this.auth.getCredentials()); + if (!credentials.ok) throw new ClaudeHttpError(401, credentials.error.message, "codex_auth_unavailable"); + const headers = credentials.value.authHeaders; + if (headers["chatgpt-account-id"] !== req.headers["chatgpt-account-id"] || !headers["authorization"]) { + throw new ClaudeHttpError( + 401, + "Native Codex account does not match the configured Codex credential store.", + "codex_account_mismatch", + ); + } + return [...req.rawHeaders, "authorization", headers["authorization"]]; + } +} diff --git a/src/codex-route.ts b/src/codex-route.ts new file mode 100644 index 0000000..45ee001 --- /dev/null +++ b/src/codex-route.ts @@ -0,0 +1,26 @@ +export type ClaudeResolution = + | { readonly kind: "claude"; readonly model: string } + | { readonly kind: "foreign" } + | { readonly kind: "absent" }; + +export type CodexRoute = + | { readonly kind: "claude"; readonly model: string } + | { readonly kind: "parent" } + | { readonly kind: "rejected"; readonly code: "translated_compaction_unavailable" }; + +/** Resolution precedes dispatch; HTTP and WebSocket dispatch do no model-name matching. */ +export const decideCodexRoute = (path: string, resolution: ClaudeResolution): CodexRoute => { + switch (resolution.kind) { + case "claude": + return path === "/responses/compact" + ? { kind: "rejected", code: "translated_compaction_unavailable" } + : { kind: "claude", model: resolution.model }; + case "foreign": + case "absent": + return { kind: "parent" }; + default: { + const exhaustive: never = resolution; + return exhaustive; + } + } +}; diff --git a/src/codex-upstream.ts b/src/codex-upstream.ts new file mode 100644 index 0000000..f3649b8 --- /dev/null +++ b/src/codex-upstream.ts @@ -0,0 +1,182 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { pipeline } from "node:stream/promises"; +import type { Config } from "./config.js"; +import type { Logger } from "./logger.js"; +import type { OpenaiPassthrough } from "./openai-passthrough.js"; +import type { CodexNativeAuth } from "./codex-native-auth.js"; +import type { CodexEndpointMode } from "./codex-ingress.js"; +import type { ClaudeCache } from "./claude-cache.js"; +import { filterRawHeaders, HOP_BY_HOP, RESPONSE_STRIP } from "./raw-http-passthrough.js"; +import { SYNTHESIZED_HEADER, SYNTHESIZED_MARKER } from "./errors.js"; +import { createSseParser, type SseEvent } from "./codex-response.js"; +import { createFrameWriter } from "./provider-transport.js"; +import { namespaceEvent } from "./collaboration-compat.js"; +import { augmentCodexModels } from "./claude-models.js"; +import { object } from "./plain-object.js"; +import { json } from "./codex-body.js"; +import { decodeBody as decode } from "./content-encoding.js"; +import { ReverseContractError } from "./claude-errors.js"; +import { OPENAI_EVENTS } from "./provider-events.js"; + +/** Response adaptation is a strategy on the shared raw HTTP transport. */ +export class CodexUpstream { + constructor( + private readonly options: { + config: Config; + logger: Logger; + raw: OpenaiPassthrough; + nativeAuth: CodexNativeAuth; + cache: ClaudeCache; + closed: () => boolean; + onError: (res: ServerResponse, error: unknown) => void; + }, + ) {} + http( + req: IncomingMessage, + res: ServerResponse, + mode: CodexEndpointMode, + path: string, + body: Buffer | undefined, + transform: Transform, + rawHeaders: readonly string[], + ): void { + if (this.options.closed() || res.destroyed) return; + this.options.raw.http( + req, + res, + mode, + path, + { kind: "complete", bytes: body ?? Buffer.alloc(0) }, + prepareHeaders(rawHeaders, transform, body), + { + onUnauthorized: this.options.nativeAuth.canRefresh(req, mode, path) + ? async () => prepareHeaders(await this.options.nativeAuth.headers(req, mode, path, true), transform, body) + : undefined, + onError: (error) => this.options.onError(res, error), + onResponse: (response) => this.respond(response, res, transform), + }, + ); + } + private async respond(response: IncomingMessage, res: ServerResponse, transform: Transform): Promise { + const status = response.statusCode ?? 502; + if (transform === "raw" || status < 200 || status >= 300) { + res.writeHead(status, filterRawHeaders(response.rawHeaders, RESPONSE_STRIP)); + await pipeline(response, res); + return; + } + const headers = [ + ...filterRawHeaders( + response.rawHeaders, + new Set([...RESPONSE_STRIP, "content-length", "content-encoding", ...(transform === "models" ? ["etag"] : [])]), + ), + SYNTHESIZED_HEADER, + SYNTHESIZED_MARKER, + ]; + if ( + transform === "namespace-stream" || + (transform === "namespace" && String(response.headers["content-type"]).includes("text/event-stream")) + ) + await this.stream(response, res, status, headers); + else await this.buffered(response, res, status, headers, transform === "models"); + } + private remember(value: unknown): void { + const response = object(value); + if (typeof response?.["id"] === "string") this.options.cache.put("adapted", response["id"], true); + } + private async stream( + response: IncomingMessage, + res: ServerResponse, + status: number, + headers: string[], + ): Promise { + res.writeHead(status, headers); + const parser = createSseParser(this.options.config.codexIngress.claude.maxSseEventBytes); + const running = pipeline(response, parser).catch((error) => { + parser.destroy(error as Error); + }); + const controller = new AbortController(); + const onClose = () => controller.abort(); + res.once("close", onClose); + const write = createFrameWriter(res, controller.signal); + try { + for await (const frame of parser as AsyncIterable) { + if (frame.data === "[DONE]") { + await write("data: [DONE]\n\n"); + continue; + } + const event = parseEvent(Buffer.from(frame.data)); + if (event["type"] === "response.completed") { + this.options.logger.log("info", OPENAI_EVENTS.responseComplete); + this.remember(event["response"]); + } + await write(`data: ${JSON.stringify(event)}\n\n`); + } + await running; + res.end(); + } finally { + res.off("close", onClose); + parser.destroy(); + await running; + } + } + private async buffered( + response: IncomingMessage, + res: ServerResponse, + status: number, + headers: string[], + models: boolean, + ): Promise { + const limit = this.options.config.limits.maxBufferedBodyBytes; + const parts: Buffer[] = []; + let size = 0; + for await (const chunk of response) { + size += chunk.length; + if (size > limit) throw new ReverseContractError("discovery_response_too_large"); + parts.push(chunk); + } + let parsed: Record; + try { + parsed = json(await decode(Buffer.concat(parts), response.headers["content-encoding"], limit)); + } catch { + throw new ReverseContractError("invalid_upstream_body"); + } + const result = models + ? augmentCodexModels(parsed, this.options.config.codexIngress.claude.aliases) + : adaptEvent({ type: "response.completed", response: parsed })["response"]; + if (!models) this.remember(result); + res.writeHead(status, headers); + res.end(JSON.stringify(result)); + } +} + +type Transform = "namespace" | "namespace-stream" | "models" | "raw"; +const prepareHeaders = (source: readonly string[], transform: Transform, body?: Buffer): string[] => { + const strip = + transform === "raw" + ? HOP_BY_HOP + : new Set([ + ...HOP_BY_HOP, + "content-length", + "content-encoding", + "accept-encoding", + ...(transform === "models" ? ["if-none-match", "if-modified-since"] : []), + ]); + const headers = filterRawHeaders(source, body ? new Set([...strip, "content-length"]) : strip); + if (transform !== "raw") headers.push("accept-encoding", "identity"); + if (body) headers.push("content-length", String(body.length)); + return headers; +}; +const parseEvent = (raw: Buffer): Record => { + try { + return adaptEvent(json(raw)); + } catch { + throw new ReverseContractError("invalid_upstream_body"); + } +}; +const adaptEvent = (event: Record): Record => { + try { + return namespaceEvent(event); + } catch { + throw new ReverseContractError("invalid_upstream_body"); + } +}; diff --git a/src/codex-ws.ts b/src/codex-ws.ts new file mode 100644 index 0000000..2c4ae95 --- /dev/null +++ b/src/codex-ws.ts @@ -0,0 +1,290 @@ +import { boundTcpConnect } from "./tcp-connect.js"; +import http, { type IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { WebSocket, WebSocketServer } from "ws"; +import type { Config } from "./config.js"; +import type { Logger } from "./logger.js"; +import { rejectCodexUpgrade, type OpenaiPassthrough } from "./openai-passthrough.js"; +import type { CodexNativeAuth } from "./codex-native-auth.js"; +import type { WebSocketBudget } from "./websocket-budget.js"; +import type { CodexEndpointMode } from "./codex-ingress.js"; +import { filterRawHeaders, HOP_BY_HOP, RESPONSE_STRIP } from "./raw-http-passthrough.js"; +import { json } from "./codex-body.js"; +import { namespaceEvent } from "./collaboration-compat.js"; +import { object, type ObjectValue as Item } from "./plain-object.js"; +import { claudeFailure, ReverseContractError } from "./claude-errors.js"; +import { openaiWebSocketError } from "./errors.js"; +import { OPENAI_EVENTS } from "./provider-events.js"; +import { decideCodexRoute, type ClaudeResolution } from "./codex-route.js"; + +/** Owns handshake, client/upstream bridging, cancellation, and upgraded socket teardown. */ +export class CodexWebSockets { + private readonly wss: WebSocketServer; + private readonly upstreamSockets = new Set(); + private readonly pendingUpgrades = new Set(); + private closed = false; + constructor( + private readonly options: { + config: Config; + logger: Logger; + raw: OpenaiPassthrough; + nativeAuth: CodexNativeAuth; + budget: WebSocketBudget; + controllers: Set; + destination: (body: Item) => ClaudeResolution; + parentRequest: (body: Item) => Item; + events: (body: Item, model: string, signal: AbortSignal, session?: string) => AsyncGenerator; + correlation: (req: IncomingMessage) => string | undefined; + }, + ) { + this.wss = new WebSocketServer({ noServer: true, maxPayload: options.config.limits.maxBufferedBodyBytes }); + } + close(): void { + this.closed = true; + for (const socket of this.pendingUpgrades) socket.destroy(); + for (const socket of this.wss.clients) socket.terminate(); + for (const socket of this.upstreamSockets) socket.terminate(); + this.wss.close(); + } + private target(mode: CodexEndpointMode, path: string): URL { + return new URL( + `${(mode === "subscription" ? this.options.config.codexIngress.subscriptionBaseUrl : this.options.config.codexIngress.apiBaseUrl).replace(/\/$/, "")}${path}`, + ); + } + upgrade(req: IncomingMessage, socket: Duplex, head: Buffer, mode: CodexEndpointMode, path: string): void { + if (req.method !== "GET" || req.headers.upgrade?.toLowerCase() !== "websocket") { + rejectCodexUpgrade(req, socket, 400, "expected a WebSocket upgrade"); + return; + } + this.options.budget.run(socket, () => { + socket.pause(); + this.pendingUpgrades.add(socket); + socket.once("close", () => this.pendingUpgrades.delete(socket)); + void this.options.nativeAuth + .headers(req, mode, path) + .then((headers) => { + if (!this.closed && !socket.destroyed) this.upgradeReady(req, socket, head, mode, path, headers); + }) + .catch((error) => { + const failure = claudeFailure(error); + rejectCodexUpgrade(req, socket, failure.status, failure.message); + }); + }); + } + private upgradeReady( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + mode: CodexEndpointMode, + path: string, + nativeHeaders: readonly string[], + refreshed = false, + ): void { + if (req.method !== "GET" || req.headers.upgrade?.toLowerCase() !== "websocket") { + rejectCodexUpgrade(req, socket, 400, "expected a WebSocket upgrade"); + return; + } + if (!this.options.config.codexIngress.claude.enabled || path.split("?")[0] !== "/responses") { + this.pendingUpgrades.delete(socket); + this.options.raw.upgrade(req, socket, head, mode, path, nativeHeaders); + return; + } + const target = this.target(mode, path); + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; + const flat = filterRawHeaders( + nativeHeaders, + new Set([ + ...HOP_BY_HOP, + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + ]), + ); + const headers: Record = {}; + for (let index = 0; index < flat.length; index += 2) headers[flat[index]!] = flat[index + 1]!; + const protocols = + typeof req.headers["sec-websocket-protocol"] === "string" + ? req.headers["sec-websocket-protocol"].split(",").map((value) => value.trim()) + : []; + let upstream: WebSocket; + try { + upstream = new WebSocket(target, protocols, { + headers, + maxPayload: this.options.config.limits.maxBufferedBodyBytes, + finishRequest: (request) => { + boundTcpConnect(request, this.options.config.codexIngress.connectTimeoutMs); + request.once("timeout", () => + request.destroy(Object.assign(new Error("OpenAI connection timed out"), { code: "ETIMEDOUT" })), + ); + request.end(); + }, + }); + } catch { + rejectCodexUpgrade(req, socket, 400, "invalid WebSocket handshake"); + return; + } + this.upstreamSockets.add(upstream); + let accepted = false; + const earlyClose = () => upstream.terminate(); + socket.once("close", earlyClose); + upstream.once("unexpected-response", (_request, response) => { + accepted = true; // HTTP rejection owns the socket; do not assign a second response on close/error. + if (response.statusCode === 401 && !refreshed && this.options.nativeAuth.canRefresh(req, mode, path)) { + socket.off("close", earlyClose); + response.destroy(); + upstream.terminate(); + void this.options.nativeAuth + .headers(req, mode, path, true) + .then((headers) => { + if (!this.closed && !socket.destroyed) this.upgradeReady(req, socket, head, mode, path, headers, true); + }) + .catch((error) => { + const failure = claudeFailure(error); + rejectCodexUpgrade(req, socket, failure.status, failure.message); + }); + return; + } + this.options.logger.log("warn", OPENAI_EVENTS.websocketRejected, { status: response.statusCode ?? 502 }); + const local = new http.ServerResponse(req); + local.assignSocket(req.socket); + socket.resume(); + local.once("finish", () => socket.end()); + local.writeHead(response.statusCode ?? 502, filterRawHeaders(response.rawHeaders, RESPONSE_STRIP)); + response.pipe(local); + response.once("end", () => upstream.terminate()); + }); + upstream.once("error", (error) => { + if (!accepted) + rejectCodexUpgrade( + req, + socket, + (error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? 504 : 502, + "OpenAI WebSocket connection failed", + ); + }); + upstream.once("close", () => this.upstreamSockets.delete(upstream)); + upstream.once("open", () => { + if (socket.destroyed) { + upstream.terminate(); + return; + } + this.wss.handleUpgrade(req, socket, head, (client) => { + this.pendingUpgrades.delete(socket); + accepted = true; + socket.off("close", earlyClose); + this.bridge(req, socket, client, upstream); + }); + }); + } + private bridge(req: IncomingMessage, socket: Duplex, client: WebSocket, upstream: WebSocket): void { + const active = new Map(); + let forwardedOpenai = false; + const send = (event: Item): Promise => + new Promise((resolve, reject) => { + if (client.readyState !== WebSocket.OPEN) { + resolve(); + return; + } + client.send(JSON.stringify(event), (error) => (error ? reject(error) : resolve())); + }); + const failure = (error: unknown, streamId?: string) => { + const result = claudeFailure(error); + void send(openaiWebSocketError(result, streamId)).catch(() => undefined); + }; + client.on("error", () => undefined); + client.once("close", () => { + for (const controller of active.values()) controller.abort(); + upstream.terminate(); + }); + upstream.on("error", () => { + if (forwardedOpenai && client.readyState === WebSocket.OPEN) client.close(1011, "OpenAI connection failed"); + }); + upstream.once("close", () => { + if (forwardedOpenai && client.readyState === WebSocket.OPEN) client.close(); + }); + let responseQueue = Promise.resolve(); + let queuedResponses = 0; + upstream.on("message", (data) => { + upstream.pause(); + queuedResponses++; + responseQueue = responseQueue + .then(async () => { + const event = namespaceEvent(json(Buffer.from(data.toString()))); + if (event["type"] === "response.completed") this.options.logger.log("info", OPENAI_EVENTS.responseComplete); + await send(event); + }) + .catch((error) => failure(error)) + .finally(() => { + if (--queuedResponses === 0 && upstream.readyState === WebSocket.OPEN) upstream.resume(); + }); + }); + client.on("message", (data) => { + if (this.closed || client.readyState !== WebSocket.OPEN) return; + let body: Item; + try { + body = json(Buffer.from(data.toString())); + } catch (error) { + failure(error); + return; + } + const streamId = typeof body["stream_id"] === "string" ? body["stream_id"] : undefined; + if (body["type"] === "response.cancel") { + const target = typeof body["response_id"] === "string" ? body["response_id"] : (streamId ?? "default"); + const controller = active.get(target); + if (controller) { + controller.abort(); + return; + } + if (target.startsWith("resp_subswitch_")) return; + } + const route = decideCodexRoute("/responses", this.options.destination(body)); + if (route.kind === "rejected") { + failure(new ReverseContractError(route.code), streamId); + return; + } + if (route.kind === "parent") { + if (upstream.readyState !== WebSocket.OPEN) { + client.close(1012, "Reconnect OpenAI stream"); + return; + } + try { + forwardedOpenai = true; + upstream.send(JSON.stringify(this.options.parentRequest(body))); + } catch (error) { + failure(error, streamId); + } + return; + } + const model = route.model; + const key = streamId ?? "default"; + if (active.has(key)) { + failure(new ReverseContractError("concurrent_claude_stream_id"), streamId); + return; + } + const controller = new AbortController(); + active.set(key, controller); + this.options.controllers.add(controller); + void (async () => { + try { + for await (const event of this.options.events( + body, + model, + controller.signal, + this.options.correlation(req), + )) { + const response = object(event["response"]); + if (typeof response?.["id"] === "string") active.set(response["id"], controller); + await send({ ...event, ...(streamId ? { stream_id: streamId } : {}) }); + } + } catch (error) { + if (!controller.signal.aborted) failure(error, streamId); + } finally { + for (const [name, candidate] of active) if (candidate === controller) active.delete(name); + this.options.controllers.delete(controller); + } + })(); + }); + socket.resume(); + } +} diff --git a/src/collaboration-compat.ts b/src/collaboration-compat.ts new file mode 100644 index 0000000..9e6ca9d --- /dev/null +++ b/src/collaboration-compat.ts @@ -0,0 +1,122 @@ +/** Reversible collaboration namespace compatibility for reverse-enabled Codex sessions. */ +export const BRIDGE_NAMESPACE = "subswitch_collaboration"; +const NATIVE_NAMESPACE = "collaboration"; +const MESSAGE_TOOLS = new Set(["spawn_agent", "send_message", "followup_task"]); +const TOOLS = new Set([...MESSAGE_TOOLS, "wait_agent", "list_agents", "interrupt_agent"]); +import { object, type ObjectValue as Item } from "./plain-object.js"; +import { ReverseContractError } from "./claude-errors.js"; + +const mapChanged = (values: unknown[], map: (value: unknown) => unknown): unknown[] => { + const mapped = values.map(map); + return mapped.some((value, index) => value !== values[index]) ? mapped : values; +}; + +export class NamespaceContractError extends ReverseContractError { + constructor(readonly code: "namespace_collision" | "invalid_plaintext_call") { + super(code); + } +} + +function definitions(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return mapChanged(value, (entry: unknown) => { + const tool = object(entry); + if (tool?.["type"] !== "namespace") return entry; + if (tool["name"] === BRIDGE_NAMESPACE) throw new NamespaceContractError("namespace_collision"); + if (tool["name"] !== NATIVE_NAMESPACE || !Array.isArray(tool["tools"])) return entry; + const tools = tool["tools"].map((value: unknown) => { + const fn = object(value); + if (!fn || !MESSAGE_TOOLS.has(String(fn["name"]))) return value; + const parameters = object(fn["parameters"]), + properties = object(parameters?.["properties"]); + const message = object(properties?.["message"]); + if (!parameters || !properties || !message) throw new NamespaceContractError("invalid_plaintext_call"); + return { + ...fn, + parameters: { ...parameters, properties: { ...properties, message: { ...message, encrypted: false } } }, + }; + }); + return { ...tool, name: BRIDGE_NAMESPACE, tools }; + }); +} + +function inputItem(value: unknown): unknown { + const item = object(value); + if (item?.["type"] === "additional_tools") { + const tools = definitions(item["tools"]); + return tools === item["tools"] ? value : { ...item, tools }; + } + if (item?.["type"] === "function_call" && item["namespace"] === NATIVE_NAMESPACE) + return { ...item, namespace: BRIDGE_NAMESPACE }; + return value; +} + +function toolChoice(value: unknown, depth = 0): unknown { + if (depth > 128) throw new ReverseContractError("json_nesting_too_deep"); + const choice = object(value); + if (choice?.["type"] === "function" && choice["namespace"] === NATIVE_NAMESPACE) + return { ...choice, namespace: BRIDGE_NAMESPACE }; + if (choice?.["type"] === "allowed_tools" && Array.isArray(choice["tools"])) { + const tools = mapChanged(choice["tools"], (entry) => toolChoice(entry, depth + 1)); + return tools === choice["tools"] ? value : { ...choice, tools }; + } + return value; +} + +/** Map structured protocol fields only. Never replace text inside prompts or arguments. */ +export function namespaceRequest(request: Item): Item { + const tools = definitions(request["tools"]); + const input = Array.isArray(request["input"]) ? mapChanged(request["input"], inputItem) : request["input"]; + const choice = toolChoice(request["tool_choice"]); + // Preserve identity on the ordinary OpenAI path: no whole-history stringify comparison. + if (tools === request["tools"] && input === request["input"] && choice === request["tool_choice"]) return request; + return { + ...request, + ...(Object.hasOwn(request, "tools") ? { tools } : {}), + ...(Object.hasOwn(request, "input") ? { input } : {}), + ...(Object.hasOwn(request, "tool_choice") ? { tool_choice: choice } : {}), + }; +} + +function outputItem(value: unknown, complete: boolean): unknown { + const item = object(value); + if (item?.["type"] !== "function_call" || item["namespace"] !== BRIDGE_NAMESPACE) return value; + if (!TOOLS.has(String(item["name"]))) throw new NamespaceContractError("invalid_plaintext_call"); + if (!MESSAGE_TOOLS.has(String(item["name"]))) return { ...item, namespace: NATIVE_NAMESPACE }; + let args: Item | undefined; + try { + args = object(JSON.parse(String(item["arguments"]))); + } catch { + /* Reject truncated JSON. */ + } + const encrypted = item["encrypted_function_args"]; + if ( + (complete && typeof args?.["message"] !== "string") || + (encrypted !== undefined && (!Array.isArray(encrypted) || encrypted.length !== 0)) + ) { + throw new NamespaceContractError("invalid_plaintext_call"); + } + // Keep call metadata consistent from added through done. Only a complete, + // validated done event can become an executable native call. + // This response belongs to the explicitly unencrypted ordinary-tool contract. + // Existing native or opaque histories never pass through this marking path. + return { ...item, namespace: NATIVE_NAMESPACE, encrypted_function_args: [] }; +} + +export function namespaceEvent(event: Item): Item { + const response = object(event["response"]); + return { + ...event, + ...(Object.hasOwn(event, "item") + ? { item: outputItem(event["item"], event["type"] === "response.output_item.done") } + : {}), + ...(response && Array.isArray(response["output"]) + ? { + response: { + ...response, + output: response["output"].map((item) => outputItem(item, event["type"] === "response.completed")), + }, + } + : {}), + }; +} diff --git a/src/config.ts b/src/config.ts index 488b938..67b7898 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import { type Result, ok, err } from "./result.js"; import type { ProxyError } from "./errors.js"; import type { LogLevel } from "./logger.js"; import { isReservedAnthropicName, PROVIDER_IDS, type ProviderId, type AliasesByProvider } from "./models.js"; +import { validClaudeAlias } from "./claude-models.js"; export const DEFAULT_PORT = 4141 as const; @@ -303,12 +304,59 @@ const LimitsSchema = z }) .prefault({}); +const ClaudeProviderSchema = z.strictObject({ + enabled: z.boolean().default(false), + baseUrl: z.url().refine(requireHttpsOrLoopback, { message: HTTPS_REQUIRED_MESSAGE }).default("https://api.anthropic.com"), + oauthTokenUrl: z.url().refine(requireHttpsOrLoopback, { message: HTTPS_REQUIRED_MESSAGE }).default("https://platform.claude.com/v1/oauth/token"), + configDir: z.string().min(1).optional(), + authFile: z.string().min(1).optional(), + aliases: z.record(z.string().min(1).max(200), z.string().min(1).max(200)).refine(value => + Object.entries(value).every(([key, target]) => validClaudeAlias(key, target)), + { message: "Claude aliases must target claude-* IDs and must not claim OpenAI model names" }).default({}), + allowInsecureBaseUrl: z.boolean().default(false), + requestTimeoutMs: z.number().int().positive().default(600_000), + streamIdleTimeoutMs: z.number().int().positive().default(300_000), + maxSseEventBytes: z.number().int().positive().default(4 * 1024 * 1024), + maxAggregateBytes: z.number().int().positive().default(64 * 1024 * 1024), + reasoningCache: z.strictObject({ maxEntries: z.number().int().positive().default(4096), maxBytes: z.number().int().positive().default(64 * 1024 * 1024) }).prefault({}), +}).superRefine((value, ctx) => { + for (const key of ["baseUrl", "oauthTokenUrl"] as const) { + const url = new URL(value[key]); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) + ctx.addIssue({ code: "custom", path: [key], message: "use an HTTP(S) URL without credentials, query, or fragment" }); + } +}).prefault({}); +export type ClaudeProviderConfig = z.infer; + +/** Native Codex ingress, with opt-in Claude routing. */ +const CodexIngressSchema = z.strictObject({ + enabled: z.boolean().default(false), + subscriptionBaseUrl: z.url().refine(requireHttpsOrLoopback, { message: HTTPS_REQUIRED_MESSAGE }) + .default("https://chatgpt.com/backend-api/codex"), + apiBaseUrl: z.url().refine(requireHttpsOrLoopback, { message: HTTPS_REQUIRED_MESSAGE }) + .default("https://api.openai.com/v1"), + connectTimeoutMs: z.number().int().positive().default(10_000), + maxUpstreamSockets: z.number().int().positive().default(256), + allowInsecureBaseUrl: z.boolean().default(false), + claude: ClaudeProviderSchema, +}).superRefine((value, ctx) => { + for (const key of ["subscriptionBaseUrl", "apiBaseUrl"] as const) { + const url = new URL(value[key]); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + ctx.addIssue({ code: "custom", path: [key], message: "use an HTTP(S) base URL without credentials, query, or fragment" }); + } + } +}).prefault({}); + +export type CodexIngressConfig = z.infer; + const FileConfigSchema = z.strictObject({ port: z.number().int().min(1).max(65535).default(DEFAULT_PORT), logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"), anthropic: AnthropicSchema, providers: ProvidersSchema, limits: LimitsSchema, + codexIngress: CodexIngressSchema, }); /** Raw on-disk config shape — what FileConfigSchema.safeParse() produces. */ @@ -370,6 +418,7 @@ export type ProviderConfigs = { readonly [K in ProviderId]: ProviderConfigShape[ export interface Config { readonly port: number; readonly logLevel: LogLevel; + readonly codexIngress: CodexIngressConfig; /** * The privileged default leg, not a peer provider: it has no model list, no auth * config of its own (the client's credential is forwarded verbatim, applies ADR-002), @@ -711,6 +760,7 @@ const PROVIDER_RESOLVERS: { export const resolveConfig = (file: FileConfig): Config => ({ port: file.port, logLevel: file.logLevel, + codexIngress: file.codexIngress, anthropic: { baseUrl: file.anthropic.baseUrl, connectTimeoutMs: file.anthropic.connectTimeoutMs, @@ -737,9 +787,25 @@ export interface LoadConfigOptions { readonly readFile?: (path: string) => string; /** Injectable environment variable map. Defaults to `process.env`. Used by tests. */ readonly env?: Record; + readonly globalConfigPath?: string | false; + readonly homeDir?: string; + readonly cwd?: string; +} + +export const userConfigPath = (env: Record = process.env, homeDir = env["HOME"] ?? homedir()): string => + join(env["XDG_CONFIG_HOME"] ?? join(homeDir, ".config"), "subswitch", "config.json"); + +/** Own properties only; arrays and scalars replace, nested configuration objects merge. */ +export function mergeConfigObjects(base: Record, overlay: Record): Record { + return Object.fromEntries([...new Set([...Object.keys(base), ...Object.keys(overlay)])].map(key => { + const value = Object.hasOwn(overlay, key) ? overlay[key] : base[key]; + return [key, Object.hasOwn(overlay, key) && isPlainObject(base[key]) && isPlainObject(value) ? mergeConfigObjects(base[key], value) : value]; + })); } export interface LoadConfigResult { + /** Loaded source paths in precedence order (user defaults, then project). */ + readonly configPaths: readonly string[]; readonly config: Config; readonly configPath: string; readonly fileFound: boolean; @@ -776,9 +842,10 @@ const detectConfiguredProviders = (raw: unknown): ReadonlySet => { * Path precedence (highest to lowest): * 1. explicit `configPath` option * 2. `SUBSWITCH_CONFIG` env var (tilde-expanded) - * 3. implicit `/subswitch.config.json` + * 3. implicit user config merged under `/subswitch.config.json` (project fields win) * - * Only the implicit cwd default silently falls back to pure defaults on ENOENT. + * Only implicit user/project files silently fall back to defaults on ENOENT. + * Explicit config selection bypasses the user/project merge. * An explicitly-requested path (option or SUBSWITCH_CONFIG) that is missing is an error. */ export const loadConfig = (options: LoadConfigOptions = {}): Result => { @@ -795,7 +862,7 @@ export const loadConfig = (options: LoadConfigOptions = {}): Result 0) { return err({ kind: "translate", message: - `unsupported config keys in ${resolvedPath} — ` + + `unsupported config keys in ${source.path} — ` + legacy.map(renderLegacyKeyEntry).join("; ") + `. Edit the file to match subswitch.config.example.json, or delete it to run on defaults.`, }); @@ -845,31 +936,34 @@ export const loadConfig = (options: LoadConfigOptions = {}): Result 0) { return err({ kind: "translate", message: - `unknown provider block(s) in ${resolvedPath}: ` + + `unknown provider block(s) in ${source.path}: ` + unknownProviders.map((key) => `\`providers.${key}\``).join(", ") + `. Known providers: ${PROVIDER_IDS.join(", ")}. ` + `An unrecognised provider block is ignored entirely, so leaving it in place would silently change nothing.`, }); } + } + // Step 4: validate against FileConfigSchema and resolve to runtime Config. const parsed = FileConfigSchema.safeParse(raw); if (!parsed.success) { return err({ kind: "translate", - message: `invalid config: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`, + message: `invalid config: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")} (sources: ${sources.map(source => source.path).join(" + ") || resolvedPath})`, }); } return ok({ config: resolveConfig(parsed.data), - configPath: resolvedPath, - fileFound, + configPaths: sources.map(source => source.path), + configPath: !fileFound && loadedGlobal ? loadedGlobal : resolvedPath, + fileFound: fileFound || loadedGlobal !== undefined, configuredProviders: detectConfiguredProviders(raw), }); }; diff --git a/src/content-encoding.ts b/src/content-encoding.ts new file mode 100644 index 0000000..6926a5c --- /dev/null +++ b/src/content-encoding.ts @@ -0,0 +1,23 @@ +import * as zlib from "node:zlib"; +import { promisify } from "node:util"; +import { ReverseContractError } from "./claude-errors.js"; + +// Async codecs keep compressed uploads from blocking every active stream. +const CODECS = new Map([ + ["gzip", promisify(zlib.gunzip)], + ["br", promisify(zlib.brotliDecompress)], + ["deflate", promisify(zlib.inflate)], + ...(typeof zlib.zstdDecompress === "function" ? [["zstd", promisify(zlib.zstdDecompress)] as const] : []), +]); + +export const hasNativeDecoders = (): boolean => CODECS.has("zstd"); + +export const decodeBody = async ( + raw: Buffer, encoding: string | string[] | undefined, limit: number, +): Promise => { + if (!encoding || encoding === "identity") return raw; + const codec = typeof encoding === "string" ? CODECS.get(encoding.toLowerCase()) : undefined; + if (!codec) throw new ReverseContractError("unsupported_content_encoding"); + try { return await codec(raw, { maxOutputLength: limit }); } + catch { throw new ReverseContractError("invalid_or_oversized_compressed_body"); } +}; diff --git a/src/doctor.ts b/src/doctor.ts index e6f4805..5d7cc78 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -215,6 +215,7 @@ const LABEL_WIDTH = 22; /** Format one doctor output row with a consistent label column width. */ const row = (label: string, value: string): string => ` ${label}`.padEnd(LABEL_WIDTH) + value; +export { row as doctorRow }; /** * Run all doctor checks and write output to io.write. diff --git a/src/errors.ts b/src/errors.ts index 85d2e23..9c2a26d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -130,3 +130,30 @@ export const toAnthropicErrorBody = (type: AnthropicErrorType, message: string): export const toAnthropicErrorSse = (type: AnthropicErrorType, message: string): string => `event: error\ndata: ${toAnthropicErrorBody(type, message)}\n\n`; + +/** OpenAI ingress shares the render-time redaction boundary with Anthropic ingress. */ +export const openaiError = (message: string, code = "subswitch_upstream_error") => ({ + type: "api_error", param: null, code: redactCredentials(code), message: redactCredentials(message), +}); + +export const openaiErrorBody = (message: string, code = "subswitch_upstream_error"): string => + JSON.stringify({ error: openaiError(message, code) }); + +export const openaiFailureEvent = ( + message: string, code: string, stream: { id: string; model: string; sequence: number }, +) => ({ + type: "response.failed", sequence_number: stream.sequence + 1, + response: { + id: stream.id, object: "response", model: stream.model, created_at: Math.floor(Date.now() / 1000), + status: "failed", output: [], error: openaiError(message, code), + }, +}); + +export const openaiWebSocketError = ( + failure: { status: number; message: string; code: string; retryAfter?: string | undefined }, streamId?: string, +) => ({ + ...openaiError(failure.retryAfter ? `${failure.message} (Retry-After: ${failure.retryAfter})` : failure.message, failure.code), + type: "error", status: failure.status, + ...(failure.retryAfter ? { retry_after: redactCredentials(failure.retryAfter) } : {}), + ...(streamId ? { stream_id: streamId } : {}), +}); diff --git a/src/logger.ts b/src/logger.ts index 30f82ec..10158ec 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -130,8 +130,8 @@ export const createConsoleLogger = ( // Every event name in the tree is a compile-time string literal — no config value // and no request value can become one — which is the primary control; this is // defence in depth. The guarantee is fully table-derived: all provider-scoped - // names come from providerEvents(providerId) (ProviderEvents

in provider-events.ts) - // and so are keyed to the closed ProviderId union. This includes the seven auth + // names come from the closed provider-events.ts tables (including CLAUDE_EVENTS + // and OPENAI_EVENTS for native ingress). This includes the seven auth // events in codex-auth.ts, which were formerly hardcoded `codex_*` literals // outside that table and have been brought in. const eventStr = `event=${pc.bold(renderToken(event))}`; diff --git a/src/models.ts b/src/models.ts index 22e02e2..db704ff 100644 --- a/src/models.ts +++ b/src/models.ts @@ -196,7 +196,7 @@ export const routableModelCount = (registry: readonly ModelEntry[], provider: Pr * * noUncheckedIndexedAccess: every element read uses ?? 0. */ -const compareGen = (a: readonly number[], b: readonly number[]): number => { +export const compareGen = (a: readonly number[], b: readonly number[]): number => { const len = Math.max(a.length, b.length); for (let i = 0; i < len; i++) { const ai = a[i] ?? 0; diff --git a/src/openai-passthrough.ts b/src/openai-passthrough.ts new file mode 100644 index 0000000..7612f01 --- /dev/null +++ b/src/openai-passthrough.ts @@ -0,0 +1,195 @@ +import { boundTcpConnect } from "./tcp-connect.js"; +import { WebSocketBudget } from "./websocket-budget.js"; +import { openaiErrorBody } from "./errors.js"; +import http from "node:http"; +import https from "node:https"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; +import type { CodexIngressConfig } from "./config.js"; +import type { Logger } from "./logger.js"; +import { SYNTHESIZED_HEADER, SYNTHESIZED_MARKER } from "./errors.js"; +import { type CodexEndpointMode } from "./codex-ingress.js"; +import { + createRawHttpForwarder, + filterRawHeaders, + setRawRequestHeaders, + HOP_BY_HOP, + RESPONSE_STRIP, + type ForwardedBody, + type ForwardHooks, +} from "./raw-http-passthrough.js"; + +/** HTTP error response for sockets handed off by Node's upgrade event. */ +export const rejectCodexUpgrade = (req: IncomingMessage, socket: Duplex, status: number, message: string, + errorBody: (message: string) => string = openaiErrorBody): void => { + if (socket.destroyed) return; + const response = new http.ServerResponse(req); + response.assignSocket(req.socket); + // Manually attached responses do not have http.Server's usual finish handler. + response.once("finish", () => socket.end()); + socket.resume(); + response.writeHead(status, { + "content-type": "application/json", + connection: "close", + [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER, + }); + response.end(errorBody(message)); +}; + +export interface CodexIngressEntry { + http(req: IncomingMessage, res: ServerResponse, mode: CodexEndpointMode, path: string): void; + upgrade(req: IncomingMessage, socket: Duplex, head: Buffer, mode: CodexEndpointMode, path: string): void; + close(): void; +} + +export interface OpenaiPassthrough extends CodexIngressEntry { + http( + req: IncomingMessage, + res: ServerResponse, + mode: CodexEndpointMode, + path: string, + body?: ForwardedBody, + rawHeaders?: readonly string[], + hooks?: ForwardHooks, + ): void; + upgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + mode: CodexEndpointMode, + path: string, + rawHeaders?: readonly string[], + ): void; + close(): void; +} + +/** Raw transport uses caller-supplied headers/body; native credential policy belongs to the ingress. */ +export function createOpenaiPassthrough( + config: CodexIngressConfig, + logger: Logger, + budget = new WebSocketBudget(config.maxUpstreamSockets), +): OpenaiPassthrough { + const targets = { + subscription: new URL(config.subscriptionBaseUrl), + api: new URL(config.apiBaseUrl), + }; + const options = { + connectTimeoutMs: config.connectTimeoutMs, + maxUpstreamSockets: config.maxUpstreamSockets, + logger, + errorBody: openaiErrorBody, + logPath: () => "/codex", + events: { timeout: "openai_upstream_timeout", error: "openai_upstream_error" } as const, + }; + const forwarders = { + subscription: createRawHttpForwarder({ ...options, baseUrl: config.subscriptionBaseUrl }), + api: createRawHttpForwarder({ ...options, baseUrl: config.apiBaseUrl }), + }; + const sockets = new Set(); + const pending = new Set(); + + return { + http: (req, res, mode, path, body, headers, hooks) => forwarders[mode](req, res, body, path, headers, hooks), + upgrade(req, socket, head, mode, path, rawHeaders) { + if (req.method !== "GET" || req.headers.upgrade?.toLowerCase() !== "websocket") { + rejectCodexUpgrade(req, socket, 400, "expected a WebSocket upgrade"); + return; + } + budget.run(socket, () => { + const target = targets[mode]; + const client = target.protocol === "https:" ? https : http; + socket.pause(); + sockets.add(socket); + const upstream = client.request({ + protocol: target.protocol, + hostname: target.hostname.replace(/^\[|\]$/g, ""), + ...(target.port ? { port: Number(target.port) } : {}), + method: "GET", + path: `${target.pathname.replace(/\/$/, "")}${path}`, + agent: false, + }); + pending.add(upstream); + let settled = false; + let tunnel: Duplex | undefined; + const fail = (status: number, message: string) => { + if (settled || socket.destroyed) return; + settled = true; + rejectCodexUpgrade(req, socket, status, message); + upstream.destroy(); + }; + socket.on("error", () => { + upstream.destroy(); + tunnel?.destroy(); + }); + socket.once("close", () => { + settled = true; + sockets.delete(socket); + upstream.destroy(); + tunnel?.destroy(); + }); + upstream.once("close", () => { + pending.delete(upstream); + }); + boundTcpConnect(upstream, config.connectTimeoutMs); + upstream.once("timeout", () => fail(504, "upstream timed out")); + upstream.once("error", () => fail(502, "upstream connection failed")); + upstream.once("response", (response) => { + if (settled || socket.destroyed) { + response.destroy(); + return; + } + settled = true; + // A rejected upgrade is still an upstream HTTP response. Relay its status, + // error body and Retry-After through Node so chunk framing remains correct. + const downstream = new http.ServerResponse(req); + downstream.assignSocket(req.socket); + downstream.once("finish", () => socket.end()); + downstream.writeHead(response.statusCode ?? 502, [ + ...filterRawHeaders(response.rawHeaders, RESPONSE_STRIP), + "Connection", + "close", + ]); + response.on("error", () => socket.destroy()); + response.pipe(downstream); + socket.resume(); + }); + upstream.once("upgrade", (response, upstreamSocket, upstreamHead) => { + if (settled || socket.destroyed) { + upstreamSocket.destroy(); + return; + } + settled = true; + pending.delete(upstream); + tunnel = upstreamSocket; + sockets.add(tunnel); + upstreamSocket.on("error", () => socket.destroy()); + upstreamSocket.once("close", () => { + sockets.delete(upstreamSocket); + socket.destroy(); + }); + const headers = filterRawHeaders(response.rawHeaders, RESPONSE_STRIP); + let wire = `HTTP/1.1 101 ${response.statusMessage ?? "Switching Protocols"}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n`; + for (let i = 0; i < headers.length; i += 2) wire += `${headers[i]}: ${headers[i + 1]}\r\n`; + socket.write(`${wire}\r\n`); + // Preserve bytes already read during both upgrade handshakes before piping. + if (upstreamHead.length) socket.write(upstreamHead); + if (head.length) upstreamSocket.write(head); + upstreamSocket.pipe(socket); + socket.pipe(upstreamSocket); + socket.resume(); + }); + setRawRequestHeaders(upstream, filterRawHeaders(rawHeaders ?? req.rawHeaders, HOP_BY_HOP)); + upstream.setHeader("Connection", "Upgrade"); + upstream.setHeader("Upgrade", "websocket"); + upstream.end(); + }); + }, + close() { + budget.close(); + for (const request of pending) request.destroy(); + for (const socket of sockets) socket.destroy(); + forwarders.subscription.close(); + forwarders.api.close(); + }, + }; +} diff --git a/src/plain-object.ts b/src/plain-object.ts index cd3041b..16951cb 100644 --- a/src/plain-object.ts +++ b/src/plain-object.ts @@ -7,3 +7,6 @@ */ export const isPlainObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + +export type ObjectValue = Record; +export const object = (value: unknown): ObjectValue | undefined => isPlainObject(value) ? value : undefined; diff --git a/src/provider-events.ts b/src/provider-events.ts index 43cbe53..610a62b 100644 --- a/src/provider-events.ts +++ b/src/provider-events.ts @@ -22,7 +22,7 @@ import type { ProviderId } from "./models.js"; * `FIELD_KEYS` in `logger.ts` is a different axis and is deliberately untouched: it * bounds which *fields* may be logged. Nothing here adds or widens a field. */ -export interface ProviderEvents

{ +export interface ProviderEvents

{ /** A request the translator could only partially represent. */ readonly translateWarning: `${P}_translate_warning`; /** Reasoning effort was forwarded to the upstream. */ @@ -120,7 +120,7 @@ export interface ProviderEvents

{ * Callers hold the returned record for the life of the handler or translator rather * than re-deriving per chunk, so the hot streaming path does no string work. */ -export const providerEvents =

(providerId: P): ProviderEvents

=> ({ +const scopedEvents =

(providerId: P): ProviderEvents

=> ({ translateWarning: `${providerId}_translate_warning`, effortApplied: `${providerId}_effort_applied`, upstream401Refreshing: `${providerId}_upstream_401_refreshing`, @@ -142,3 +142,14 @@ export const providerEvents =

(providerId: P): ProviderEve authFileWriteFailed: `${providerId}_auth_file_write_failed`, authFileUnreadableAfterRefresh: `${providerId}_auth_file_unreadable_after_refresh`, }); + +export const providerEvents =

(providerId: P): ProviderEvents

=> scopedEvents(providerId); +export const CLAUDE_EVENTS = { + ...scopedEvents("claude"), requestFailed: "claude_request_failed", requestComplete: "claude_request_complete", + toolCall: "claude_tool_call", toolResult: "claude_tool_result", +} as const; +export const OPENAI_EVENTS = { + ...scopedEvents("openai"), responseComplete: "codex_response_complete", websocketRejected: "openai_websocket_rejected", + compatOverWindowPassthrough: "codex_compat_over_window_passthrough", upstreamTimeout: "openai_upstream_timeout", +} as const; +export const ANTHROPIC_EVENTS = scopedEvents("anthropic"); diff --git a/src/raw-http-passthrough.ts b/src/raw-http-passthrough.ts new file mode 100644 index 0000000..89fbb7e --- /dev/null +++ b/src/raw-http-passthrough.ts @@ -0,0 +1,367 @@ +import { boundTcpConnect } from "./tcp-connect.js"; +import http from "node:http"; +import https from "node:https"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { SYNTHESIZED_HEADER, SYNTHESIZED_MARKER } from "./errors.js"; +import { drainRejectedUpload } from "./provider-transport.js"; +import type { Logger } from "./logger.js"; + +/** + * Hop-by-hop headers per RFC 7230 §6.1 — stripped in BOTH the request and response + * directions. These are connection-specific and must not be forwarded end-to-end. + * + * `host` is included: Node sets it on the outbound connection so the client-supplied + * value must not be forwarded. `proxy-connection` is a de-facto same-class extension. + * `connection` is managed by the keep-alive agent. + */ +export const HOP_BY_HOP = new Set([ + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +/** + * Additional headers stripped from the response direction (upstream → client) only. + * + * The synthesized marker is removed from proxied responses so it stays + * authoritative: only the relay itself can assert it. The name is imported from + * errors.ts rather than restated, so a rename cannot reach the emitters while + * leaving this stripper matching the old name (ADR-008 chokepoint). It is NOT + * stripped from the request direction because: + * - The relay never reads the request-side value anywhere. + * - The header is not hop-by-hop. + * - With subswitch turned off, the header would reach the origin untouched. + * Stripping it from client requests is relay-invented behaviour — the kind + * ADR-010 prohibits. + */ +export const RESPONSE_STRIP = new Set([...HOP_BY_HOP, SYNTHESIZED_HEADER]); + +/** + * Build a filtered flat [name, value, ...] array from a rawHeaders array, + * skipping headers listed in `strip` or named by any Connection header + * case-insensitively while preserving the + * original casing, order, and duplicates of every non-stripped header. + * + * The `strip` parameter is REQUIRED so every call site must declare its direction: + * - Response direction (upstream → client): pass RESPONSE_STRIP + * - Request direction (client → upstream): pass HOP_BY_HOP + * + * Node's http.ServerResponse.writeHead() accepts the flat-array form directly + * (via _storeHeader's Array branch). For the request direction we use setHeader + * calls instead (http.request options.headers uses Object.keys, not flat pairs). + */ +export const filterRawHeaders = (rawHeaders: readonly string[], strip: ReadonlySet): string[] => { + const connectionFields = new Set(); + for (let i = 0; i + 1 < rawHeaders.length; i += 2) { + if (rawHeaders[i]!.toLowerCase() === "connection") { + for (const token of rawHeaders[i + 1]!.split(",")) connectionFields.add(token.trim().toLowerCase()); + } + } + const filtered: string[] = []; + for (let i = 0; i + 1 < rawHeaders.length; i += 2) { + const name = rawHeaders[i]!; + const value = rawHeaders[i + 1]!; + const lower = name.toLowerCase(); + if (!strip.has(lower) && !connectionFields.has(lower)) { + filtered.push(name, value); + } + } + return filtered; +}; + +export const setRawRequestHeaders = (request: http.ClientRequest, rawHeaders: readonly string[]): void => { + const headerMap = new Map(); + for (let i = 0; i + 1 < rawHeaders.length; i += 2) { + const name = rawHeaders[i]!; + const value = rawHeaders[i + 1]!; + const key = name.toLowerCase(); + const entry = headerMap.get(key); + if (entry === undefined) headerMap.set(key, { name, values: [value] }); + else entry.values.push(value); + } + for (const { name, values } of headerMap.values()) { + request.setHeader(name, values.length === 1 ? values[0]! : values); + } +}; + +export interface PassthroughOptions { + readonly errorBody: (message: string) => string; + readonly logPath: (req: IncomingMessage) => string; + readonly events: { + readonly timeout: Parameters[1]; + readonly error: Parameters[1]; + }; + readonly baseUrl: string; + /** + * Bounds TCP connection establishment only (milliseconds). + * The timer is armed directly on the socket (not via `ClientRequest.setTimeout`, + * which defers internally and cannot bound the connect phase). Once TCP connects, + * the timer is disarmed entirely — the relay must never bound the headers or stream + * phases on a connected client (ADR-010). + * + * On HTTPS connections, `'connect'` fires after TCP establishment but before the + * TLS handshake, so TLS negotiation is NOT covered by this budget — neither the + * headers phase nor the stream phase is bounded, consistent with ADR-010. + * + * For pooled/keep-alive sockets (no connect phase), this budget has no effect. + */ + readonly connectTimeoutMs: number; + readonly logger: Logger; + /** Maximum sockets in the keep-alive pool for this upstream. */ + readonly maxUpstreamSockets: number; + /** + * Test seam — provide a pre-built Agent to override the auto-created one. + * Production code omits this; the forwarder creates a keep-alive agent + * matching the base URL protocol. + */ + readonly agent?: http.Agent; +} + +/** + * Describes how much of the request body the caller has already read. + * + * "complete" — the full body is in `bytes`; nothing remains on `req`. The forwarder + * calls `upstream.end(bytes)` and never pipes `req`. This is the common path for + * bodies that fit within the routing window. + * + * "prefix" — `bytes` holds bytes that were already read (possibly empty); the rest + * is still flowing on `req`. The forwarder writes the prefix then pipes `req`. + * This is the over-window path: we read enough to sniff the model, then stream. + * + * Invariant: `pipe()` on a readable that has already emitted `end` never ends the + * destination — the discriminant makes that hang unrepresentable. + */ +export type ForwardedBody = + | { readonly kind: "complete"; readonly bytes: Buffer } + | { readonly kind: "prefix"; readonly bytes: Buffer }; + +export interface ForwardHooks { + readonly onResponse?: ((response: IncomingMessage) => Promise) | undefined; + readonly onUnauthorized?: (() => Promise) | undefined; + readonly onError?: ((error: unknown) => void) | undefined; +} + +export interface RawHttpForwarder { + (req: IncomingMessage, res: ServerResponse, body?: ForwardedBody, requestPath?: string, rawHeaders?: readonly string[], hooks?: ForwardHooks): void; + close(): void; +} + +export const createRawHttpForwarder = (options: PassthroughOptions): RawHttpForwarder => { + const target = new URL(options.baseUrl); + const client = target.protocol === "https:" ? https : http; + const basePath = target.pathname === "/" ? "" : target.pathname.replace(/\/$/, ""); + + // Create a keep-alive agent for persistent connections to the upstream. + // This matches Claude Code's own direct connection behaviour (parity). + const agentOpts: http.AgentOptions = { keepAlive: true, maxSockets: options.maxUpstreamSockets, scheduling: "lifo" }; + const agent = options.agent ?? (target.protocol === "https:" ? new https.Agent(agentOpts) : new http.Agent(agentOpts)); + + // Sentinel for "no bytes consumed yet" — the prefix path with an empty prefix. + // Shared across calls so allocation is constant rather than per-request. + const EMPTY_PREFIX: ForwardedBody = { kind: "prefix", bytes: Buffer.alloc(0) }; + + const requests = new Set(); + let closed = false; + const forward = (req: IncomingMessage, res: ServerResponse, body?: ForwardedBody, requestPath?: string, rawHeaders?: readonly string[], hooks?: ForwardHooks): void => { + if (closed || res.destroyed) return; + const path = `${basePath}${requestPath ?? req.url ?? "/"}` || "/"; + // Normalise: omitted means nothing has been read yet (prefix path, empty prefix). + const consumed = body ?? EMPTY_PREFIX; + // True when req still has bytes to deliver (prefix path). + const streaming = consumed.kind === "prefix"; + // One-way latch over a four-outcome terminal state machine: + // 1. upstream response headers — relayed verbatim + // 2. connect-phase timeout — synthesized 504 + // 3. upstream error — synthesized 502 + // 4. client disconnect, or a client stream error on the unbuffered path + // The first to claim it owns the client-visible outcome and the warn log; the + // rest return early, so a destroy() issued by one handler cannot produce a + // duplicate anthropic_upstream_error warn or write into a response that is + // already written or destroyed. + // + // It answers one question — has the client's outcome been decided? — and only + // that one. Resource teardown has its own predicate (res.writableFinished), + // because this latch is spent the instant headers are relayed, which is before + // the mid-stream abort teardown has to handle (PF-022). + let settled = false; + const settle = (): boolean => { + if (settled) return false; + settled = true; + return true; + }; + + const upstream = client.request( + { + protocol: target.protocol, + hostname: target.hostname.replace(/^\[|\]$/g, ""), + ...(target.port !== "" ? { port: Number(target.port) } : {}), + method: req.method ?? "GET", + path, + agent, + // No headers here — http.request uses Object.keys on the headers option, + // which would produce numeric indices for an array. We apply headers via + // setHeader calls below to preserve original casing and per-name value order + // (cross-name position of interleaved duplicates is not guaranteed). + }, + // Terminal outcome 1 (upstream response headers). + (upstreamRes) => { + if (!settle()) { upstreamRes.destroy(); return; } + // Retry only a complete body, once. An upload stream cannot be replayed safely. + if (upstreamRes.statusCode === 401 && hooks?.onUnauthorized && consumed.kind === "complete") { + upstreamRes.destroy(); upstream.destroy(); + res.off("close", onClose); + void hooks.onUnauthorized().then(headers => { + forward(req, res, consumed, requestPath, headers, { ...hooks, onUnauthorized: undefined }); + }).catch(error => hooks.onError ? hooks.onError(error) : res.destroy()); + return; + } + if (hooks?.onResponse) { + void hooks.onResponse(upstreamRes).catch(error => { + if (hooks.onError) hooks.onError(error); else res.destroy(); + }).finally(() => upstream.destroy()); + return; + } + // Response direction: writeHead accepts a flat [name, value, ...] array + // directly (Node's _storeHeader Array branch), preserving the upstream's + // original header casing, order, and duplicates byte-for-byte. + // filterRawHeaders strips SYNTHESIZED_HEADER so an origin that sets it + // cannot impersonate the relay's synthesized-response marker. + res.writeHead(upstreamRes.statusCode ?? 502, filterRawHeaders(upstreamRes.rawHeaders, RESPONSE_STRIP)); + res.socket?.setNoDelay(true); + upstreamRes.pipe(res); + upstreamRes.on("error", () => res.destroy()); + }, + ); + + requests.add(upstream); + upstream.once("close", () => requests.delete(upstream)); + + // Timer arming — single-budget design (ADR-010): + // + // connectTimeoutMs — bounds TCP establishment ONLY, armed DIRECTLY on the + // socket (not via upstream.setTimeout). ClientRequest.setTimeout() defers + // internally via its own 'connect' listener, so it would fire only after + // connect and cannot bound the connect phase. + // + // Node v22's internal socket-timeout handler (onTimeout) skips + // req.emit('timeout') when socket.connecting is true, so we propagate the + // timeout manually via upstream.emit('timeout'). + // + // On connect the timer is DISARMED entirely: the relay must never bound the + // headers-phase or stream-phase on a connected client (ADR-010). + // + // For pooled/keep-alive sockets (no connect phase) this budget has no effect. + boundTcpConnect(upstream, options.connectTimeoutMs); + + // Request direction: build a Map from the filtered rawHeaders so that + // duplicates (same lowercase key, different values) are preserved as array + // values, and setHeader sends them with original name casing. Caveat: + // interleaved duplicates of the same name (A,B,A) are regrouped adjacent + // (A,A,B); per-name value order is preserved (RFC 7230 §3.2.2) and adjacent + // duplicates are byte-exact. Anthropic clients do not interleave duplicate + // header names, so this is safe in practice. + const filteredRaw = filterRawHeaders(rawHeaders ?? req.rawHeaders, HOP_BY_HOP); + setRawRequestHeaders(upstream, filteredRaw); + + // Terminal outcome 2 (connect timeout): only the connect-phase timeout can fire; + // no other timer is ever armed. settle() ensures the error handler does + // not produce a duplicate warn after destroy() is called. + // + // On the unbuffered path the client is often still uploading when this fires, and + // its bytes are piped into the upstream that is about to be destroyed. The upload + // is unpiped and handed to drainRejectedUpload, which reads it out under the same + // time and byte bounds the 413 path uses. Without that, the 504 is written but the + // connection is dead weight: Node cannot parse the next request out of a body it + // never consumed, so the socket is held until server.requestTimeout (600 s) with + // the client still pushing into it (measured). The buffered path needs none of + // this — the request was fully consumed before the upstream was opened. + upstream.on("timeout", () => { + if (!settle()) return; + options.logger.log("warn", options.events.timeout, { path: options.logPath(req) }); + if (!res.headersSent) { + res.writeHead(504, { "content-type": "application/json", [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER }); + res.end(options.errorBody("upstream timed out")); + } else { + res.destroy(); + } + if (streaming) { + req.unpipe(upstream); + drainRejectedUpload(req); + } + upstream.destroy(); + }); + + // Terminal outcome 3 (upstream error): settle() prevents a double-warn if + // destroy() from the timeout handler produces an ECONNRESET on the next tick. + // + // The unbuffered path needs the same upload drain as the timeout handler above, + // for the same reason and under the same bounds — the client is often still + // uploading into an upstream that has already failed, and a 502 written onto a + // connection whose body was never consumed leaves it dead weight until + // server.requestTimeout (measured — I-079 sibling, applies ADR-010). + upstream.on("error", () => { + if (!settle()) return; + options.logger.log("warn", options.events.error, { path: options.logPath(req) }); + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json", [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER }); + res.end(options.errorBody("upstream connection failed")); + } else { + res.destroy(); + } + if (streaming) { + req.unpipe(upstream); + drainRejectedUpload(req); + } + }); + + // Terminal outcome 4 (client disconnect). + // + // Teardown is unconditional on an unfinished response: `res.writableFinished` is + // the predicate, never the latch (PF-022). `pipe()` does not propagate + // destination teardown to the source, so an upstream left alive here stays + // half-read and its socket never returns to the agent's free pool; at + // maxUpstreamSockets the pool exhausts and every later request queues inside + // http.Agent indefinitely — a hang no origin can produce (ADR-010). + // + // The latch is claimed on the way through so the ECONNRESET that destroy() raises + // a tick later cannot warn or write a 502 into a closed response. Its return is + // discarded: a client abort owns this outcome either way, and is normal — no warn + // log, no synthetic HTTP response. + const onClose = () => { + if (res.writableFinished) return; + void settle(); + upstream.destroy(); + }; + res.once("close", onClose); + + if (consumed.kind === "complete") { + // Full body already buffered — end the upstream in one write. No pipe, no + // client-stream error handler needed: req has delivered everything it will. + upstream.end(consumed.bytes); + } else { + // Prefix path: write the bytes we already read, then pipe the remainder. + // Invariant (stated in ForwardedBody): pipe() on a readable that has already + // emitted "end" never ends the destination — the discriminant makes that hang + // unrepresentable. + if (consumed.bytes.length > 0) upstream.write(consumed.bytes); + req.pipe(upstream); + // A failed client stream takes the upstream down with it, and claims the + // settlement on the way out for the same reason the res "close" handler does: + // the ECONNRESET that destroy() raises a tick later must not be reported as an + // origin failure or answered with a 502 the origin never sent. The return is + // discarded — a client fault owns the outcome whether or not the latch was open. + req.on("error", () => { + void settle(); + upstream.destroy(); + }); + } + }; + return Object.assign(forward, { close: () => { closed = true; for (const request of requests) request.destroy(); agent.destroy(); } }); +}; diff --git a/src/server.ts b/src/server.ts index 79f1532..4152d74 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,15 @@ +import { vetCredentialUrl } from "./upstream-policy.js"; +import { ClaudeAuthManager, createClaudeCredentialStore } from "./claude-auth.js"; +import { openaiErrorBody } from "./errors.js"; import http from "node:http"; import { existsSync } from "node:fs"; import type { IncomingMessage, Server } from "node:http"; import { toAnthropicErrorBody, SYNTHESIZED_HEADER, SYNTHESIZED_MARKER } from "./errors.js"; import { applyInboundPolicy, hostGateVerdict, SERVER_TUNING } from "./inbound-policy.js"; import { type Result, ok, err } from "./result.js"; -import { aliasesByProvider, enumerateDestinations, isLoopbackHost, providerConfigFor, type Config } from "./config.js"; +import { aliasesByProvider, enumerateDestinations, providerConfigFor, type Config } from "./config.js"; import { createConsoleLogger, type Logger } from "./logger.js"; -import { providerEvents } from "./provider-events.js"; +import { CLAUDE_EVENTS, OPENAI_EVENTS, ANTHROPIC_EVENTS, providerEvents } from "./provider-events.js"; import { decideRoute } from "./router.js"; import { createAnthropicForwarder, type AnthropicForwarder, type ForwardedBody } from "./anthropic-passthrough.js"; import { sniffLeadingModel, MODEL_SNIFF_BYTES } from "./anthropic-parse.js"; @@ -27,12 +30,19 @@ import { } from "./models.js"; import { SUBSWITCH_NAME, SUBSWITCH_VERSION } from "./version.js"; import type { ProviderHandler } from "./provider-handler.js"; +import { codexIngressRoute } from "./codex-ingress.js"; +import { rejectCodexUpgrade, type CodexIngressEntry } from "./openai-passthrough.js"; +import { CodexGateway } from "./codex-gateway.js"; +import { hasNativeDecoders } from "./content-encoding.js"; +import { claudeResolver } from "./claude-models.js"; +import { codexIngressHealth } from "./codex-health.js"; export interface ServerDeps { readonly config: Config; readonly logger: Logger; /** Privileged default leg — handles everything that is not POST /v1/messages*. */ readonly forwardAnthropic: AnthropicForwarder; + readonly forwardOpenai: CodexIngressEntry | undefined; /** * Provider dispatch table. `Record` — NOT `Partial`, NOT `Map`. * Adding a `ProviderId` without a handler is a compile error: the whole point of @@ -55,17 +65,13 @@ export interface ServerDeps { * only allocated when a Codex provider is actually wired — not unconditionally * for every process. (applies ADR-002) */ -const createCodexProvider = (config: Config, logger: Logger): ProviderHandler => { +const createCodexAuth = (config: Config, logger: Logger): ProviderAuth<"codex"> => new CodexAuthManager({ + store: createFsAuthFileStore(config.providers.codex.authFile), + oauthTokenUrl: config.providers.codex.oauthTokenUrl, logger, events: providerEvents("codex"), +}); + +const createCodexProvider = (config: Config, logger: Logger, auth: ProviderAuth<"codex">): ProviderHandler => { const provider = config.providers.codex; - // Annotated rather than inferred: conformance is then checked here as well as at - // `implements ProviderAuth<"codex">`, so an edit to CodexAuthManager that broke the - // brand fails at the wiring site too — the place a mismatched credential enters. - const auth: ProviderAuth<"codex"> = new CodexAuthManager({ - store: createFsAuthFileStore(provider.authFile), - oauthTokenUrl: provider.oauthTokenUrl, - logger, - events: providerEvents("codex"), - }); return createCodexHandler({ providerId: "codex", provider, @@ -94,99 +100,38 @@ const createCodexProvider = (config: Config, logger: Logger): ProviderHandler => */ export const buildDeps = (config: Config, logger: Logger = createConsoleLogger(config.logLevel)): Result => { - // Vet each provider's credential-carrying URLs at startup. - // - // Two controls, both per-provider by construction (one provider's defaultHost cannot - // accidentally vet another's baseUrl): - // 1. SCHEME: http:// to a non-loopback host sends credentials in cleartext — warn. - // Loopback (127.*/localhost/::1) is exempt; the e2e dev workflow uses - // http://127.0.0.1:4142 intentionally. - // 2. HOST: a URL on a different hostname than this provider's expected default - // sends credentials to a third-party host — FATAL unless allowInsecureBaseUrl. - // Loopback hosts are always exempt. - // - // Both baseUrl (short-lived access token) and oauthTokenUrl (long-lived refresh - // token) are swept. oauthTokenUrl carries the more damaging credential. - // - // Anthropic baseUrl is checked separately below (not a ProviderId, so it is outside - // this loop, but the threat model is the same: a sk-ant-* key forwarded to a - // non-default host). - // - // z.url() in the config schema validates URL format; z.refine(requireHttpsOrLoopback) - // rejects http:// non-loopback at parse time — this loop is defence in depth and also - // catches programmatically-constructed Config objects that bypass Zod. - for (const id of PROVIDER_IDS) { - const { baseUrl, defaultHost, oauthTokenUrl, defaultOauthHost, allowInsecureBaseUrl } = providerConfigFor(config, id); - const events = providerEvents(id); - - // Check baseUrl scheme and hostname. - // new URL() is safe: z.url() already validated the URL at config-parse time. - const parsedBase = new URL(baseUrl); - if (!isLoopbackHost(parsedBase.hostname)) { - if (parsedBase.protocol !== "https:") { - logger.log("warn", events.insecureBaseUrlScheme); - } - if (parsedBase.hostname !== defaultHost) { - if (!allowInsecureBaseUrl) { - logger.log("error", events.baseUrlHostRejected, { path: `providers.${id}.baseUrl` }); - return err( - `providers.${id}.baseUrl points at '${parsedBase.hostname}' (expected '${defaultHost}'). ` + - `Credentials would be sent to an untrusted host. ` + - `Set "providers.${id}.allowInsecureBaseUrl": true in subswitch.config.json to opt in.`, - ); - } - logger.log("warn", events.baseUrlOverrideDetected); - } - } + if (config.codexIngress.enabled && config.codexIngress.claude.enabled && !hasNativeDecoders()) + return err("Codex → Claude routing requires native zstd support. Use Node 22.15 or newer; forward routing remains available."); - // Check oauthTokenUrl scheme and hostname (present only for OAuth providers). - // oauthTokenUrl carries the long-lived refresh token — more damaging to expose than - // the short-lived access token in baseUrl. - if (oauthTokenUrl !== undefined && defaultOauthHost !== undefined) { - const parsedOauth = new URL(oauthTokenUrl); - if (!isLoopbackHost(parsedOauth.hostname)) { - if (parsedOauth.protocol !== "https:") { - logger.log("warn", events.insecureBaseUrlScheme); - } - if (parsedOauth.hostname !== defaultOauthHost) { - if (!allowInsecureBaseUrl) { - logger.log("error", events.baseUrlHostRejected, { path: `providers.${id}.oauthTokenUrl` }); - return err( - `providers.${id}.oauthTokenUrl points at '${parsedOauth.hostname}' (expected '${defaultOauthHost}'). ` + - `Your long-lived refresh token would be sent to an untrusted host. ` + - `Set "providers.${id}.allowInsecureBaseUrl": true in subswitch.config.json to opt in.`, - ); - } - logger.log("warn", events.baseUrlOverrideDetected); - } - } - } + // All credential-bearing URL checks share the same opt-in vocabulary and diagnostics. + for (const [key, expectedHost] of [["baseUrl", "api.anthropic.com"], ["oauthTokenUrl", "platform.claude.com"]] as const) { + const result = vetCredentialUrl({ url: config.codexIngress.claude[key], path: `codexIngress.claude.${key}`, + expectedHost, optInKey: "codexIngress.claude.allowInsecureBaseUrl", allowOverride: config.codexIngress.claude.allowInsecureBaseUrl, + logger, events: CLAUDE_EVENTS, refreshToken: key === "oauthTokenUrl" }); + if (!result.ok) return result; } - - // Anthropic leg: same threat model — a sk-ant-* key forwarded verbatim to a - // non-default host. `anthropic` is not a ProviderId, so the check is separate and - // uses hardcoded event-name literals, following the existing pattern for - // "anthropic_insecure_base_url_scheme". - { - const ANTHROPIC_DEFAULT_HOST = "api.anthropic.com"; - const parsedAnthropic = new URL(config.anthropic.baseUrl); - if (!isLoopbackHost(parsedAnthropic.hostname)) { - if (parsedAnthropic.protocol !== "https:") { - logger.log("warn", "anthropic_insecure_base_url_scheme"); - } - if (parsedAnthropic.hostname !== ANTHROPIC_DEFAULT_HOST) { - if (!config.anthropic.allowInsecureBaseUrl) { - logger.log("error", "anthropic_base_url_host_rejected", { path: "anthropic.baseUrl" }); - return err( - `anthropic.baseUrl points at '${parsedAnthropic.hostname}' (expected '${ANTHROPIC_DEFAULT_HOST}'). ` + - `Credentials would be sent to an untrusted host. ` + - `Set "anthropic.allowInsecureBaseUrl": true in subswitch.config.json to opt in.`, - ); - } - logger.log("warn", "anthropic_base_url_override_detected"); - } + for (const [key, expectedHost] of [["subscriptionBaseUrl", "chatgpt.com"], ["apiBaseUrl", "api.openai.com"]] as const) { + const result = vetCredentialUrl({ url: config.codexIngress[key], path: `codexIngress.${key}`, + expectedHost, optInKey: "codexIngress.allowInsecureBaseUrl", allowOverride: config.codexIngress.allowInsecureBaseUrl, + logger, events: OPENAI_EVENTS }); + if (!result.ok) return result; + } + for (const id of PROVIDER_IDS) { + const provider = providerConfigFor(config, id); + const urls = [ + { url: provider.baseUrl, expectedHost: provider.defaultHost, key: "baseUrl", refreshToken: false }, + ...(provider.oauthTokenUrl && provider.defaultOauthHost ? [{ url: provider.oauthTokenUrl, + expectedHost: provider.defaultOauthHost, key: "oauthTokenUrl", refreshToken: true }] : []), + ]; + for (const url of urls) { + const result = vetCredentialUrl({ ...url, path: `providers.${id}.${url.key}`, optInKey: `providers.${id}.allowInsecureBaseUrl`, + allowOverride: provider.allowInsecureBaseUrl, logger, events: providerEvents(id) }); + if (!result.ok) return result; } } + const anthropic = vetCredentialUrl({ url: config.anthropic.baseUrl, path: "anthropic.baseUrl", expectedHost: "api.anthropic.com", + optInKey: "anthropic.allowInsecureBaseUrl", allowOverride: config.anthropic.allowInsecureBaseUrl, logger, events: ANTHROPIC_EVENTS }); + if (!anthropic.ok) return anthropic; // Build the routing table once. The resolver is a pure closure over this table; // "built once at startup" is a structural guarantee, not a comment. (applies ADR-005) @@ -211,9 +156,17 @@ export const buildDeps = (config: Config, logger: Logger = createConsoleLogger(c logger.log("warn", "registry_entry_uses_reserved_name", { model: id }); } + const resolveClaude = claudeResolver(config.codexIngress.claude.aliases); + if (resolveClaude.rejectedAliases.length) return err(`Invalid Claude aliases: ${resolveClaude.rejectedAliases.join(", ")}`); + const codexAuth = createCodexAuth(config, logger); + return ok({ config, logger, + forwardOpenai: config.codexIngress.enabled ? new CodexGateway({ config, logger, parentAuth: codexAuth, resolveClaude, + claudeAuth: new ClaudeAuthManager({ store: createClaudeCredentialStore(config.codexIngress.claude), + oauthTokenUrl: config.codexIngress.claude.oauthTokenUrl, logger }), + }) : undefined, forwardAnthropic: createAnthropicForwarder({ baseUrl: config.anthropic.baseUrl, connectTimeoutMs: config.anthropic.connectTimeoutMs, @@ -221,7 +174,7 @@ export const buildDeps = (config: Config, logger: Logger = createConsoleLogger(c logger, }), providers: { - codex: createCodexProvider(config, logger), + codex: createCodexProvider(config, logger, codexAuth), }, resolve: (name) => resolveModelFromTable(table, name), }); @@ -262,6 +215,7 @@ const buildHealthBody = (config: Config): string => JSON.stringify({ name: SUBSWITCH_NAME, version: SUBSWITCH_VERSION, + codexIngress: codexIngressHealth(config), providers: enumerateDestinations(config).map((d) => { if (d.routing === "passthrough") { // Anthropic is always reachable — no auth file, no model list. (applies ADR-002) @@ -397,13 +351,27 @@ const synthesizedHeaders = (): Record => ({ [SYNTHESIZED_HEADER]: SYNTHESIZED_MARKER, }); +/** Own upgraded-connection shutdown before Node waits for listener connections to drain. */ +class SubswitchServer extends http.Server { + constructor(private readonly closeUpstreamConnections: () => void, handler: http.RequestListener) { + super({ maxHeaderSize: SERVER_TUNING.maxHeaderSize }, handler); + } + + override close(callback?: (error?: Error) => void): this { + this.closeUpstreamConnections(); + return super.close(callback); + } +} + export const createProxyServer = (deps: ServerDeps): Server => { const { config, logger } = deps; - const server = http.createServer({ maxHeaderSize: SERVER_TUNING.maxHeaderSize }, (req, res) => { + const server = new SubswitchServer(() => { deps.forwardOpenai?.close(); deps.forwardAnthropic.close?.(); }, (req, res) => { const startedAt = Date.now(); const path = req.url ?? "/"; const pathname = path.split("?")[0] ?? path; + const ingress = codexIngressRoute(path); + const logPath = ingress.kind === "other" ? pathname : "/codex"; let model: string | undefined; let route = "anthropic"; let bodyMode: "buffered" | "streamed" | undefined; @@ -415,7 +383,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { // client that vanished mid-upload identically to a served request. if (!res.headersSent) { logger.log("info", "client_disconnected", { - path: pathname, + path: logPath, route, ...(model !== undefined ? { model } : {}), latencyMs: Date.now() - startedAt, @@ -423,7 +391,7 @@ export const createProxyServer = (deps: ServerDeps): Server => { return; } logger.log("info", "request_complete", { - path: pathname, + path: logPath, route, ...(model !== undefined ? { model } : {}), ...(bodyMode !== undefined ? { bodyMode } : {}), @@ -451,13 +419,14 @@ export const createProxyServer = (deps: ServerDeps): Server => { route = "host_rejected"; // The rejected value is sanitised and capped by hostGateVerdict; the body // carries a fixed message and never echoes it back to the caller. - logger.log("warn", "host_rejected", { path: pathname, errorCode: `${gate.reason} ${gate.observed}`, status: 403 }); + logger.log("warn", "host_rejected", { path: logPath, errorCode: `${gate.reason} ${gate.observed}`, status: 403 }); // 403/permission_error: a status and type the origin itself emits (applies // ADR-010 — a Host naming a domain this relay does not serve is a request the // origin would never have received), rendered through the error chokepoint // (applies ADR-008). res.writeHead(403, synthesizedHeaders()); - res.end(toAnthropicErrorBody("permission_error", gate.message)); + res.end(ingress.kind === "other" ? toAnthropicErrorBody("permission_error", gate.message) : + openaiErrorBody(gate.message, "subswitch_host_rejected")); // The upload may still be in flight. Same reasoning as the 413 below: // destroying the socket here makes the kernel send RST and the client may // discard the 403 it was just sent. @@ -465,6 +434,20 @@ export const createProxyServer = (deps: ServerDeps): Server => { return; } + // Reserve the whole Codex namespace before the Claude-facing fallback. + if (ingress.kind !== "other") { + route = ingress.kind === "codex" ? `codex_ingress:${ingress.mode}:passthrough` : "codex_ingress:unknown"; + if (ingress.kind === "reserved" || !deps.forwardOpenai) { + res.writeHead(ingress.kind === "reserved" ? 404 : 503, synthesizedHeaders()); + res.end(openaiErrorBody(ingress.kind === "reserved" ? "unknown Codex ingress path" : + "Codex passthrough is disabled; enable codexIngress.enabled to use this endpoint")); + drainRejectedUpload(req); + return; + } + deps.forwardOpenai.http(req, res, ingress.mode, ingress.path); + return; + } + // /__subswitch/* namespace: handled locally, never forwarded upstream. if (pathname.startsWith("/__subswitch/")) { if (req.method === "GET" && pathname === "/__subswitch/health") { @@ -635,10 +618,11 @@ export const createProxyServer = (deps: ServerDeps): Server => { dispatch().catch((cause: unknown) => { route = "internal_error"; - logger.log("error", "request_failed", { path: pathname, errorCode: cause instanceof Error ? cause.name : "unknown" }); + logger.log("error", "request_failed", { path: logPath, errorCode: cause instanceof Error ? cause.name : "unknown" }); if (!res.headersSent) { res.writeHead(500, synthesizedHeaders()); - res.end(toAnthropicErrorBody("api_error", "subswitch internal error — this is a proxy fault, not an upstream failure")); + const message = "subswitch internal error — this is a proxy fault, not an upstream failure"; + res.end(ingress.kind === "other" ? toAnthropicErrorBody("api_error", message) : openaiErrorBody(message)); } else if (!res.writableEnded) { res.destroy(); } @@ -652,8 +636,19 @@ export const createProxyServer = (deps: ServerDeps): Server => { // clientError handler that owns the responses those knobs produce. One call — // the two halves are not separately applicable by design (PF-021). // `maxHeaderSize` is the exception: it is constructor-only and is passed into - // http.createServer above. + // SubswitchServer above. applyInboundPolicy(server, logger); + server.on("upgrade", (req, socket, head) => { + socket.on("error", () => socket.destroy()); + const gate = hostGateVerdict(req.headers); + const ingress = codexIngressRoute(req.url ?? "/"); + const reject = (status: number, message: string) => rejectCodexUpgrade(req, socket, status, message, + ingress.kind === "other" ? text => toAnthropicErrorBody(status === 403 ? "permission_error" : "not_found_error", text) : openaiErrorBody); + if (gate.kind === "reject") { reject(403, gate.message); return; } + if (ingress.kind !== "codex") { reject(404, "unknown upgrade path"); return; } + if (!deps.forwardOpenai) { rejectCodexUpgrade(req, socket, 503, "Codex passthrough is disabled"); return; } + deps.forwardOpenai.upgrade(req, socket, head, ingress.mode, ingress.path); + }); return server; }; diff --git a/src/tcp-connect.ts b/src/tcp-connect.ts new file mode 100644 index 0000000..8c0e92a --- /dev/null +++ b/src/tcp-connect.ts @@ -0,0 +1,24 @@ +import type { ClientRequest } from "node:http"; + +/** ADR-010: bound DNS/TCP establishment only, then disarm before TLS/headers/streaming. */ +export const boundTcpConnect = (request: ClientRequest, timeoutMs: number): void => { + request.once("socket", (socket) => { + socket.setNoDelay(true); + if (!socket.connecting) return; + const clear = () => { + socket.off("timeout", timeout); + socket.off("connect", clear); + request.off("close", clear); + socket.setTimeout(0); + }; + const timeout = () => { + clear(); + // Node suppresses request timeout propagation while socket.connecting is true. + request.emit("timeout"); + }; + socket.setTimeout(timeoutMs); + socket.once("timeout", timeout); + socket.once("connect", clear); + request.once("close", clear); + }); +}; diff --git a/src/upstream-policy.ts b/src/upstream-policy.ts new file mode 100644 index 0000000..7fe61aa --- /dev/null +++ b/src/upstream-policy.ts @@ -0,0 +1,30 @@ +import { isLoopbackHost } from "./config.js"; +import type { Logger } from "./logger.js"; +import { ok, err, type Result } from "./result.js"; + +export const vetCredentialUrl = (options: { + url: string; + path: string; + expectedHost: string; + optInKey: string; + allowOverride: boolean; + logger: Logger; + refreshToken?: boolean; + events: { insecureBaseUrlScheme: string; baseUrlHostRejected: string; baseUrlOverrideDetected: string }; +}): Result => { + const { path, logger, events } = options; + const url = new URL(options.url); + if (isLoopbackHost(url.hostname)) return ok(undefined); + if (url.protocol !== "https:") logger.log("warn", events.insecureBaseUrlScheme); + if (url.hostname === options.expectedHost && (!url.port || url.port === "443")) return ok(undefined); + if (!options.allowOverride) { + logger.log("error", events.baseUrlHostRejected, { path }); + return err( + `${path} points at '${url.host}' (expected '${options.expectedHost}'). ` + + `${options.refreshToken ? "Your long-lived refresh token" : "Credentials"} would be sent to an untrusted host. ` + + `Set "${options.optInKey}": true in subswitch.config.json to opt in.`, + ); + } + logger.log("warn", events.baseUrlOverrideDetected); + return ok(undefined); +}; diff --git a/src/websocket-budget.ts b/src/websocket-budget.ts new file mode 100644 index 0000000..fc5d08e --- /dev/null +++ b/src/websocket-budget.ts @@ -0,0 +1,43 @@ +import type { Duplex } from "node:stream"; + +/** Upgraded sockets leave http.Agent's pool, so their whole lifetime needs its own bound. */ +export class WebSocketBudget { + private readonly active = new Set(); + private readonly waiting = new Map void>(); + private closed = false; + constructor(private readonly limit: number) {} + run(socket: Duplex, start: () => void): void { + if (this.closed || socket.destroyed) { + socket.destroy(); + return; + } + if (this.active.has(socket)) { + start(); + return; + } + socket.pause(); + socket.once("close", () => { + this.active.delete(socket); + this.waiting.delete(socket); + this.drain(); + }); + this.waiting.set(socket, start); + this.drain(); + } + private drain(): void { + if (this.closed) return; + for (const [socket, start] of this.waiting) { + if (this.active.size >= this.limit) break; + this.waiting.delete(socket); + if (socket.destroyed) continue; + this.active.add(socket); + start(); + } + } + close(): void { + this.closed = true; + for (const socket of [...this.active, ...this.waiting.keys()]) socket.destroy(); + this.waiting.clear(); + this.active.clear(); + } +} diff --git a/subswitch.config.example.json b/subswitch.config.example.json index d041388..739d324 100644 --- a/subswitch.config.example.json +++ b/subswitch.config.example.json @@ -7,6 +7,29 @@ "maxUpstreamSockets": 256, "allowInsecureBaseUrl": false }, + "codexIngress": { + "enabled": false, + "subscriptionBaseUrl": "https://chatgpt.com/backend-api/codex", + "apiBaseUrl": "https://api.openai.com/v1", + "connectTimeoutMs": 10000, + "maxUpstreamSockets": 256, + "allowInsecureBaseUrl": false, + "claude": { + "enabled": false, + "baseUrl": "https://api.anthropic.com", + "oauthTokenUrl": "https://platform.claude.com/v1/oauth/token", + "aliases": {}, + "allowInsecureBaseUrl": false, + "requestTimeoutMs": 600000, + "streamIdleTimeoutMs": 300000, + "maxSseEventBytes": 4194304, + "maxAggregateBytes": 67108864, + "reasoningCache": { + "maxEntries": 4096, + "maxBytes": 67108864 + } + } + }, "providers": { "codex": { "baseUrl": "https://chatgpt.com/backend-api/codex", diff --git a/test/fixtures/native/codex-0.153.3-collaboration.json b/test/fixtures/native/codex-0.153.3-collaboration.json new file mode 100644 index 0000000..3caf30c --- /dev/null +++ b/test/fixtures/native/codex-0.153.3-collaboration.json @@ -0,0 +1,145 @@ +{ + "type": "namespace", + "name": "collaboration", + "description": "Tools for spawning and managing sub-agents.", + "tools": [ + { + "type": "function", + "name": "followup_task", + "description": "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message text to send to the target agent.", + "encrypted": true + }, + "target": { + "type": "string", + "description": "Agent id or canonical task name to send a follow-up task to (from spawn_agent)." + } + }, + "required": [ + "target", + "message" + ], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "interrupt_agent", + "description": "Interrupt an agent's current turn, if any, and return its previous status. The agent remains available for messages and follow-up tasks.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Agent id or canonical task name to interrupt (from spawn_agent)." + } + }, + "required": [ + "target" + ], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "list_agents", + "description": "List live agents in the current root thread tree. Optionally filter by task-path prefix.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "path_prefix": { + "type": "string", + "description": "Task-path prefix filter without a trailing slash. Omit to list all live agents." + } + }, + "additionalProperties": false + } + }, + { + "type": "function", + "name": "send_message", + "description": "Send a message to an existing agent. The message will be delivered promptly. Does not trigger a new turn.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message text to queue on the target agent.", + "encrypted": true + }, + "target": { + "type": "string", + "description": "Relative or canonical task name to message (from spawn_agent)." + } + }, + "required": [ + "target", + "message" + ], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "spawn_agent", + "description": "\n Available model overrides (optional; inherited parent model is preferred):\n- `gpt-5.6-sol`: Latest frontier agentic coding model. Reasoning efforts: low (default), medium, high, xhigh, max, ultra. Service tiers: priority, ultrafast.\n- `gpt-5.6-terra`: Balanced agentic coding model for everyday work. Reasoning efforts: low, medium (default), high, xhigh, max, ultra. Service tiers: priority.\n- `gpt-5.6-luna`: Fast and affordable agentic coding model. Reasoning efforts: low, medium (default), high, xhigh, max. Service tiers: priority.\n- `gpt-5.5`: Frontier model for complex coding, research, and real-world work. Reasoning efforts: low, medium (default), high, xhigh. Service tiers: priority.\n- `gpt-5.2`: Optimized for professional work and long-running agents. Reasoning efforts: low, medium (default), high, xhigh.\n Spawns an agent to work on the specified task. If your current task is `/root/task1` and you spawn_agent with task_name \"task_3\" the agent will have canonical task name `/root/task1/task_3`.\nYou are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`.\nThe spawned agent will have the same tools as you and the ability to spawn its own subagents.\n\nOnly call this tool for a concrete, bounded subtask that can run independently alongside useful local work; otherwise continue locally.\nIt will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.\nThe new agent's canonical task name will be provided to it along with the message.\n\nNote that passing `fork_turns=\"none\"` will not pass any surrounding context to the spawned subagent, which may cause the agent to lack the context it needs to complete its task, whereas `fork_turns=\"all\"` will provide the subagent with all surrounding context.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "fork_turns": { + "type": "string", + "description": "Optional number of turns to fork. Defaults to `all`. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns." + }, + "message": { + "type": "string", + "description": "Initial plain-text task for the new agent.", + "encrypted": true + }, + "model": { + "type": "string", + "description": "Model override for the new agent. Omit unless an explicit override is needed." + }, + "reasoning_effort": { + "type": "string", + "description": "Reasoning effort override for the new agent. Omit to inherit the parent effort." + }, + "task_name": { + "type": "string", + "description": "Task name for the new agent. Use lowercase letters, digits, and underscores." + } + }, + "required": [ + "task_name", + "message" + ], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "wait_agent", + "description": "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. The wait also ends early when new user input is steered into the active turn. Does not return the content; returns either a summary of which agents have updates (if any), an interruption summary for steered input, or a timeout summary if no activity arrives before the deadline.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "timeout_ms": { + "type": "number", + "description": "Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000." + } + }, + "additionalProperties": false + } + } + ] +} diff --git a/test/fixtures/native/codex-0.153.3-model.json b/test/fixtures/native/codex-0.153.3-model.json new file mode 100644 index 0000000..926b5c3 --- /dev/null +++ b/test/fixtures/native/codex-0.153.3-model.json @@ -0,0 +1,83 @@ +{ + "slug": "gpt-6-astra", + "display_name": "GPT-6-Astra", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "effort": "high", + "description": "Greater reasoning depth for complex problems" + }, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems" + }, + { + "effort": "max", + "description": "Maximum reasoning depth for the hardest problems" + }, + { + "effort": "ultra", + "description": "Maximum reasoning with automatic task delegation" + } + ], + "shell_type": "unified_exec", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "additional_speed_tiers": [ + "fast" + ], + "service_tiers": [ + { + "id": "priority", + "name": "Fast", + "description": "2x speed, increased usage" + } + ], + "default_service_tier": "priority", + "include_skills_usage_instructions": false, + "include_plugin_usage_instructions": false, + "include_apps_usage_instructions": false, + "default_reasoning_summary": "none", + "support_verbosity": true, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "supports_image_detail_original": true, + "context_window": 272000, + "max_context_window": 872000, + "effective_context_window_percent": 95, + "experimental_supported_tools": [ + "send_user_message_async", + "clock" + ], + "input_modalities": [ + "text", + "image" + ], + "supports_search_tool": true, + "use_responses_lite": true, + "node_repl_auto_review_required": true, + "node_repl_disabled": false, + "tool_mode": "code_mode_only", + "multi_agent_version": "v2", + "multi_agent_reasoning_effort": "xhigh", + "description": "Fabricated native contract model", + "base_instructions": "Perform the fixed native contract check. Follow the provided tool definitions.", + "model_messages": null, + "availability_nux": null, + "upgrade": null, + "comp_hash": null +} diff --git a/test/integration/codex-ingress.test.ts b/test/integration/codex-ingress.test.ts new file mode 100644 index 0000000..2b70b6a --- /dev/null +++ b/test/integration/codex-ingress.test.ts @@ -0,0 +1,302 @@ +import assert from "node:assert/strict"; +import { describe, it, after } from "node:test"; +import http from "node:http"; +import net from "node:net"; +import { once } from "node:events"; +import { gzipSync, brotliCompressSync, deflateSync } from "node:zlib"; +import type { AddressInfo } from "node:net"; +import { codexIngressRoute } from "../../src/codex-ingress.js"; +import { loadConfig } from "../../src/config.js"; +import { buildDeps } from "../../src/server.js"; +import { startFakeUpstream, startSubswitch, rawHttpRequest } from "./fake-upstreams.js"; + +const cleanups: (() => Promise)[] = []; +after(async () => { for (const cleanup of cleanups.reverse()) await cleanup(); }); + +async function setup(handler: Parameters[0], config: Record = {}) { + const anthropic = await startFakeUpstream((_req, res) => res.end("anthropic")); + const subscription = await startFakeUpstream(handler); + const api = await startFakeUpstream(handler); + const logs: unknown[] = []; + const proxy = await startSubswitch({ + anthropic: { baseUrl: anthropic.url }, + codexIngress: { enabled: true, subscriptionBaseUrl: `${subscription.url}/backend-api/codex`, apiBaseUrl: `${api.url}/v1`, ...config }, + limits: { maxBufferedBodyBytes: 16 }, + }, { logger: { log: (level, event, fields) => logs.push({ level, event, fields }) } }); + cleanups.push(anthropic.close, subscription.close, api.close, proxy.close); + return { anthropic, subscription, api, proxy, logs }; +} + +async function rawUpgrade(url: string, options: { origin?: string; path?: string; head?: Buffer } = {}) { + const parsed = new URL(url); + const socket = net.createConnection({ host: parsed.hostname, port: Number(parsed.port) }); + let bytes = Buffer.alloc(0); + socket.on("data", (chunk) => { bytes = Buffer.concat([bytes, chunk]); }); + socket.on("error", () => undefined); + await once(socket, "connect"); + const request = Buffer.from([ + `GET ${options.path ?? "/codex/v1/responses?probe=yes"} HTTP/1.1`, `Host: ${parsed.host}`, + "Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: ZmFrZS1wcm9iZS1rZXktMQ==", "Authorization: Bearer native-credential", + ...(options.origin ? [`Origin: ${options.origin}`] : []), "", "", + ].join("\r\n")); + socket.write(Buffer.concat([request, options.head ?? Buffer.alloc(0)])); + const waitFor = async (predicate: (bytes: Buffer) => boolean) => { + const deadline = Date.now() + 3000; + while (!predicate(bytes)) { + assert.ok(Date.now() < deadline, "timed out reading raw upgrade"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return bytes; + }; + cleanups.push(async () => { socket.destroy(); }); + await waitFor((data) => data.includes("\r\n\r\n")); + return { socket, waitFor, bytes: () => bytes }; +} + +describe("Codex namespace and raw HTTP ingress", () => { + it("uses exact namespace boundaries and preserves path/query bytes", () => { + assert.deepEqual(codexIngressRoute("/codex/v1/responses?x=%2f&x=two"), { + kind: "codex", mode: "api", path: "/responses?x=%2f&x=two", + }); + assert.deepEqual(codexIngressRoute("/codex/backend-api/codex?x=1"), { kind: "codex", mode: "subscription", path: "?x=1" }); + for (const path of ["/codex", "/codex/nope", "/codex/v10/responses", "/codex/backend-api/codexx/responses"]) { + assert.equal(codexIngressRoute(path).kind, "reserved"); + } + for (const path of ["/v1/messages", "/codex-other"]) assert.equal(codexIngressRoute(path).kind, "other"); + }); + + it("uses the originating error envelope for non-Codex upgrades", async () => { + const { proxy } = await setup((_req, res) => res.end()); + const rejected = await rawUpgrade(proxy.url, { path: "/v1/messages", origin: "https://foreign.example" }); + assert.match(rejected.bytes().toString(), /403/); + assert.match(rejected.bytes().toString(), /"type":"error","error":\{"type":"permission_error"/); + const missing = await rawUpgrade(proxy.url, { path: "/v1/messages" }); + assert.match(missing.bytes().toString(), /404/); + assert.match(missing.bytes().toString(), /"type":"not_found_error"/); + }); + + it("keeps disabled and unknown Codex paths away from Anthropic", async () => { + const { proxy, anthropic, subscription, api } = await setup((_req, res) => res.end(), { enabled: false }); + for (const [path, status] of [["/codex/v1/responses", 503], ["/codex/unknown", 404]] as const) { + const response = await fetch(`${proxy.url}${path}`, { method: "POST", body: "unread upload" }); + assert.equal(response.status, status); + const body = await response.json() as Record; + assert.ok(body["error"]); + assert.equal(body["type"], undefined, "must be Responses-shaped, not Anthropic-shaped"); + } + assert.equal(anthropic.requests.length + subscription.requests.length + api.requests.length, 0); + }); + + it("forwards each namespace only to its selected upstream with native credentials", async () => { + const { proxy, api, subscription, anthropic } = await setup((_req, res, body) => res.end(body)); + for (const [prefix, auth, destination, expected] of [ + ["/codex/backend-api/codex", "Bearer subscription-fake", subscription, "/backend-api/codex"], + ["/codex/v1", "Bearer api-fake", api, "/v1"], + ] as const) { + const body = '{ "model": "future-native-model", "stream": false, "input": "untouched" }'; + const response = await fetch(`${proxy.url}${prefix}/responses?q=%2f&q=two`, { + method: "POST", headers: { authorization: auth, "chatgpt-account-id": "native-account", "content-type": "application/json" }, body, + }); + assert.equal(await response.text(), body); + assert.equal(destination.requests[0]?.url, `${expected}/responses?q=%2f&q=two`); + assert.equal(destination.requests[0]?.headers["authorization"], auth); + assert.equal(destination.requests[0]?.headers["chatgpt-account-id"], "native-account"); + assert.equal(destination.requests[0]?.headers["anthropic-version"], undefined); + assert.equal(destination.requests[0]?.body.toString(), body); + } + assert.equal(anthropic.requests.length, 0); + const root = await fetch(`${proxy.url}/codex/v1?probe=1`); + await root.text(); + assert.equal(api.requests.at(-1)?.url, "/v1?probe=1", "do not invent a trailing slash at the base path"); + }); + + it("does not parse, decompress, impose the translation body bound, or replace models", async () => { + const { proxy, api } = await setup((_req, res) => res.end("ok")); + const body = Buffer.from('{ "model": "claude-sonnet-5", "input": "' + "x".repeat(200_000) + '" }'); + const inputs: [string, Buffer][] = [ + ["gzip", gzipSync(body)], ["br", brotliCompressSync(body)], ["deflate", deflateSync(body)], + ["zstd", Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 1, 2, 3])], ["identity", Buffer.from("{malformed-json")], + ]; + for (const [encoding, payload] of inputs) { + const response = await fetch(`${proxy.url}/codex/v1/responses`, { + method: "POST", headers: { "content-encoding": encoding }, body: payload, + }); + await response.text(); + assert.deepEqual(api.requests.at(-1)?.body, payload); + assert.equal(api.requests.at(-1)?.headers["content-encoding"], encoding); + } + // Includes a Claude-looking model: this slice is intentionally passthrough only. + assert.equal(api.requests.length, inputs.length); + }); + + it("preserves errors, redirects, Retry-After, and native discovery/compaction responses", async () => { + const payload = '{"error":{"type":"rate_limit_error","message":"native limit"}}'; + const { proxy, api, anthropic } = await setup((req, res) => { + if (req.url?.includes("redirect")) { + res.writeHead(307, { location: "https://example.invalid/credential-target" }); res.end(); + } else if (req.url?.includes("responses")) { + res.writeHead(429, { "retry-after": "123", "x-request-id": "native-id", "x-subswitch-synthesized": "1" }); + res.end(payload); + } else { res.end('{"models":[{"slug":"native","future_metadata":true}]}'); } + }); + const response = await fetch(`${proxy.url}/codex/v1/responses/compact`, { method: "POST", body: "opaque" }); + assert.equal(response.status, 429); + assert.equal(response.headers.get("retry-after"), "123"); + assert.equal(response.headers.get("x-subswitch-synthesized"), null); + assert.equal(await response.text(), payload); + const redirect = await fetch(`${proxy.url}/codex/v1/redirect`, { redirect: "manual" }); + assert.equal(redirect.status, 307); + await redirect.text(); + const models = await fetch(`${proxy.url}/codex/v1/models?client_version=future`); + assert.equal(await models.text(), '{"models":[{"slug":"native","future_metadata":true}]}'); + assert.equal(api.requests.length, 3); + assert.equal(anthropic.requests.length, 0); + }); + + it("preserves streaming responses beyond the connect budget and logs no payload/query", async () => { + const sse = 'data: {"type":"response.completed","secret":"payload"}\n\n'; + const { proxy, logs } = await setup((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(sse.slice(0, 9)); + setTimeout(() => res.end(sse.slice(9)), 60); + }, { connectTimeoutMs: 10 }); + const response = await fetch(`${proxy.url}/codex/v1/responses?token=private-query`, { + method: "POST", headers: { authorization: "Bearer private-credential" }, body: "private-prompt", + }); + assert.equal(await response.text(), sse); + const logged = JSON.stringify(logs); + for (const secret of ["private-query", "private-credential", "private-prompt", "payload"]) assert.ok(!logged.includes(secret)); + assert.ok(logged.includes("codex_ingress:api:passthrough")); + }); + + it("returns a Responses error when connecting fails", async () => { + const { proxy, api } = await setup((_req, res) => res.end()); + await api.close(); + const response = await fetch(`${proxy.url}/codex/v1/responses`, { method: "POST", body: "upload" }); + assert.equal(response.status, 502); + assert.equal(response.headers.get("x-subswitch-synthesized"), "1"); + assert.deepEqual(await response.json(), { error: { + message: "upstream connection failed", type: "api_error", param: null, code: "subswitch_upstream_error", + } }); + }); + + it("strips headers nominated by Connection and preserves duplicate end-to-end headers", async () => { + const { proxy, api } = await setup((_req, res) => { + res.writeHead(200, ["Connection", "X-Internal", "X-Internal", "hop-only", "X-Native", "one", "X-Native", "two"]); + res.end("ok"); + }); + const response = await rawHttpRequest(`${proxy.url}/codex/v1/responses`, { + method: "POST", body: Buffer.from("test"), rawHeaders: [ + "Connection", "keep-alive, X-Local", "X-Local", "hop-only", "X-Native", "one", "X-Native", "two", + ], + }); + assert.equal(api.requests[0]?.headers["x-local"], undefined); + assert.equal(api.requests[0]?.headers["x-native"], "one, two"); + assert.ok(!response.rawHeaders.some((value) => value.toLowerCase() === "x-internal")); + const values: string[] = []; + for (let i = 0; i < response.rawHeaders.length; i += 2) { + if (response.rawHeaders[i]?.toLowerCase() === "x-native") values.push(response.rawHeaders[i + 1]!); + } + assert.deepEqual(values, ["one", "two"]); + }); + + it("reports enabled passthrough as distinct from unavailable translation", async () => { + const { proxy } = await setup((_req, res) => res.end()); + const response = await fetch(`${proxy.url}/__subswitch/health`); + const health = await response.json() as { codexIngress: unknown }; + assert.deepEqual(health.codexIngress, { + schemaVersion: 1, enabled: true, mode: "passthrough", translationAvailable: false, + credentials: "client", transports: ["http", "websocket"], + }); + }); + + it("applies Host/Origin protection to Codex HTTP requests", async () => { + const { proxy, api, subscription, anthropic } = await setup((_req, res) => res.end()); + const response = await fetch(`${proxy.url}/codex/v1/responses`, { headers: { origin: "https://foreign.example" } }); + assert.equal(response.status, 403); + const body = await response.json() as { error: { code: string } }; + assert.equal(body.error.code, "subswitch_host_rejected"); + assert.equal(api.requests.length + subscription.requests.length + anthropic.requests.length, 0); + }); + + it("validates credential destinations and defaults to disabled", () => { + const parse = (codexIngress: unknown) => loadConfig({ configPath: "inline.json", readFile: () => JSON.stringify({ codexIngress }) }); + const defaults = parse({}); + assert.ok(defaults.ok); + assert.equal(defaults.value.config.codexIngress.enabled, false); + for (const url of ["http://foreign.example", "https://user:key@api.openai.com/v1", "https://api.openai.com/v1?q=1", "ftp://localhost/path"]) { + assert.equal(parse({ apiBaseUrl: url }).ok, false); + } + for (const url of ["https://foreign.example/v1", "https://api.openai.com:444/v1"]) { + const result = parse({ enabled: true, apiBaseUrl: url }); + assert.ok(result.ok); + assert.equal(buildDeps(result.value.config).ok, false); + const explicit = parse({ enabled: true, apiBaseUrl: url, allowInsecureBaseUrl: true }); + assert.ok(explicit.ok); + const deps = buildDeps(explicit.value.config); + assert.ok(deps.ok); + deps.value.forwardOpenai?.close(); + } + }); +}); + +describe("Codex raw upgrade transport", () => { + it("tunnels client/upstream handshake head bytes, continuation frames, and connection reuse", async () => { + const upstream = http.createServer(); + let received = Buffer.alloc(0); + let requests = 0; + const upstreamHead = Buffer.from([0x81, 0x02, 0x6f, 0x6b]); + const clientHead = Buffer.from([0x89, 0x80, 1, 2, 3, 4]); + upstream.on("upgrade", (req, socket, head) => { + requests++; + assert.equal(req.url, "/v1/responses?probe=yes"); + assert.equal(req.headers.authorization, "Bearer native-credential"); + socket.on("error", () => undefined); + socket.on("end", () => socket.end()); + socket.write(Buffer.concat([Buffer.from("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: native-value\r\n\r\n"), upstreamHead])); + received = Buffer.concat([received, head]); + if (head.length) socket.write(head); + socket.on("data", (data) => { received = Buffer.concat([received, data]); socket.write(data); }); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + const proxy = await startSubswitch({ codexIngress: { enabled: true, apiBaseUrl: `${url}/v1` } }); + cleanups.push(async () => { upstream.closeAllConnections(); await new Promise((resolve) => upstream.close(() => resolve())); }, proxy.close); + const raw = await rawUpgrade(proxy.url, { head: clientHead }); + await raw.waitFor((bytes) => bytes.includes(upstreamHead) && bytes.includes(clientHead)); + const warmupAndContinuation = Buffer.from('response.create generate=false; response.create previous_response_id=resp_fixed; incremental input'); + raw.socket.write(warmupAndContinuation); + await raw.waitFor((bytes) => bytes.includes(warmupAndContinuation)); + assert.deepEqual(received, Buffer.concat([clientHead, warmupAndContinuation])); + assert.equal(requests, 1, "continuation stays on the original upstream connection"); + // Shutdown must close upgraded sockets too, without waiting for a stream deadline. + const closed = once(raw.socket, "close"); + await proxy.close(); + await closed; + }); + + it("preserves upstream upgrade rejection status, headers and body", async () => { + const { proxy, api } = await setup((_req, res) => { + res.writeHead(429, { "retry-after": "77", "content-type": "application/json" }); + res.end('{"error":{"message":"native limit"}}'); + }); + const raw = await rawUpgrade(proxy.url); + await raw.waitFor((bytes) => bytes.includes("native limit")); + const wire = raw.bytes().toString(); + assert.match(wire, /^HTTP\/1.1 429/); + assert.match(wire, /retry-after: 77/i); + assert.equal(api.requests.length, 1); + }); + + it("rejects foreign origins and unknown/disabled namespaces before connecting", async () => { + const { proxy, api, subscription, anthropic } = await setup((_req, res) => res.end(), { enabled: false }); + for (const [options, status] of [ + [{ origin: "https://foreign.example" }, 403], [{ path: "/codex/nope" }, 404], [{}, 503], + ] as const) { + const raw = await rawUpgrade(proxy.url, options); + assert.ok(raw.bytes().toString().startsWith(`HTTP/1.1 ${status}`)); + } + assert.equal(api.requests.length + subscription.requests.length + anthropic.requests.length, 0); + }); +}); diff --git a/test/integration/codex-setup.test.ts b/test/integration/codex-setup.test.ts new file mode 100644 index 0000000..bf9bddc --- /dev/null +++ b/test/integration/codex-setup.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm, access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execFile), cli = resolve("src/cli.ts"), tsx = import.meta.resolve("tsx"); +describe("Codex setup CLI", () => { + it("previews without writes, configures both clients, and preserves unrelated settings", async () => { + const temp = await mkdtemp(join(tmpdir(), "subswitch-setup-cli-")); + const project = join(temp, "project"), codex = join(temp, "codex"), xdg = join(temp, "config"); + await mkdir(join(project, ".claude"), { recursive: true }); await mkdir(codex); + const original = '# Native settings\nmodel = "gpt-6-astra"\ndeveloper_instructions = "do-not-print-this-fixture"\n'; + await writeFile(join(codex, "config.toml"), original); + await writeFile(join(project, "subswitch.config.json"), '{"codexIngress":{"enabled":false,"claude":{"enabled":false,"aliases":{"reviewer":"claude-opus-5"}}}}'); + await writeFile(join(project, ".claude", "settings.local.json"), '{"permissions":{"allow":["Read"]},"env":{"KEEP":"yes"}}'); + const env = { ...process.env, CODEX_HOME: codex, XDG_CONFIG_HOME: xdg, SUBSWITCH_CONFIG: "", FORCE_COLOR: "0" }; + const run = (...args: string[]) => exec(process.execPath, ["--import", tsx, cli, ...args], { cwd: project, env, timeout: 10000 }); + try { + const preview = await run("init", "--client", "both", "--dry-run"); + assert.match(preview.stdout, /No files written/); assert.ok(!preview.stdout.includes("do-not-print-this-fixture")); + assert.equal(await readFile(join(codex, "config.toml"), "utf8"), original); + await assert.rejects(access(join(xdg, "subswitch", "config.json"))); + await assert.rejects(run("init", "--client", "codex"), error => !!(error as { stderr?: string }).stderr?.includes("--yes")); + await run("init", "--client", "all", "--yes"); + const native = await readFile(join(codex, "config.toml"), "utf8"); + assert.match(native, /openai_base_url = "http:\/\/127.0.0.1:4141\/codex\/backend-api\/codex"/); + assert.ok(native.includes(original)); + const settings = JSON.parse(await readFile(join(project, ".claude", "settings.local.json"), "utf8")); + assert.deepEqual(settings.permissions, { allow: ["Read"] }); assert.equal(settings.env.KEEP, "yes"); assert.equal(settings.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:4141"); + const models = JSON.parse((await run("models", "--client", "codex", "--json")).stdout); + assert.equal(models.enabled, true); assert.ok(models.models.some((model: { aliases: string[] }) => model.aliases.includes("reviewer"))); + const allModels = JSON.parse((await run("models", "--client", "all", "--json")).stdout); + const legacyModels = JSON.parse((await run("models", "--client", "both", "--json")).stdout); + assert.deepEqual(allModels, legacyModels); + assert.equal(allModels.client, "all"); + assert.deepEqual(Object.keys(allModels.clients), ["claude-code", "codex"]); + await run("init", "--client", "both", "--yes"); + assert.equal(await readFile(join(codex, "config.toml"), "utf8"), native); + } finally { await rm(temp, { recursive: true, force: true }); } + }); +}); diff --git a/test/integration/reverse-websocket.test.ts b/test/integration/reverse-websocket.test.ts new file mode 100644 index 0000000..ad2f010 --- /dev/null +++ b/test/integration/reverse-websocket.test.ts @@ -0,0 +1,127 @@ +import { claudeResolver } from "../../src/claude-models.js"; +import { ClaudeAuthManager, createClaudeCredentialStore } from "../../src/claude-auth.js"; +import http from "node:http"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WebSocket, WebSocketServer } from "ws"; +import { startFakeUpstream, startSubswitch } from "./fake-upstreams.js"; +import { reverseEvents, type Item } from "../../src/claude-adapter.js"; +import { collaborationTools } from "../../e2e/gates/contracts.js"; +import { CodexGateway } from "../../src/codex-gateway.js"; +import { loadConfig } from "../../src/config.js"; +import type { ProviderCredential } from "../../src/provider-auth.js"; +import type { Result } from "../../src/result.js"; +import type { ProxyError } from "../../src/errors.js"; + +const listen = (server: http.Server) => new Promise(resolve => server.listen(0, "127.0.0.1", () => { + const address = server.address(); if (!address || typeof address === "string") throw new Error(); resolve(`http://127.0.0.1:${address.port}`); +})); +const close = (server: http.Server) => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); }); +const textStream = (text: string) => [ + { type: "message_start", message: { type: "message", usage: { input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }, + { type: "content_block_stop", index: 0 }, { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, { type: "message_stop" }, +].map(event => `data: ${JSON.stringify(event)}\n\n`).join(""); + +describe("production reverse WebSockets", () => { + it("routes each model on a reused connection and restores native collaboration names", async () => { + const temp = await mkdtemp(join(tmpdir(), "subswitch-reverse-ws-")); + const authFile = join(temp, "claude.json"); + await writeFile(authFile, JSON.stringify({ claudeAiOauth: { accessToken: "claude-ws-fixture", expiresAt: Date.now() + 3600000 } })); + const claude = await startFakeUpstream((_req, res, _body, index) => { + if (index === 2) { res.writeHead(429, { "content-type": "application/json", "retry-after": "23" }); res.end('{"error":{"type":"rate_limit_error","message":"Try later"}}'); return; } + res.writeHead(200, { "content-type": "text/event-stream" }); res.end(textStream(index ? "second reply" : "first reply")); + }); + const server = http.createServer(); const wss = new WebSocketServer({ server }); + const parentRequests: Item[] = []; let parentAuth: unknown; + wss.on("connection", (ws, req) => { + parentAuth = req.headers.authorization; + ws.on("message", data => { + parentRequests.push(JSON.parse(data.toString())); + const output = [{ type: "function_call", id: "fc_parent", call_id: "call_parent", namespace: "subswitch_collaboration", name: "spawn_agent", + arguments: '{"task_name":"worker","message":"read the fixture"}', status: "completed" }]; + for (const event of reverseEvents("resp_parent", "gpt-5.5", output)) ws.send(JSON.stringify(event)); + }); + }); + const upstream = await listen(server); + const proxy = await startSubswitch({ codexIngress: { enabled: true, apiBaseUrl: `${upstream}/v1`, claude: { enabled: true, baseUrl: claude.url, authFile } } }); + const client = new WebSocket(`${proxy.url.replace("http:", "ws:")}/codex/v1/responses`, { headers: { authorization: "Bearer parent-ws-fixture" } }); + const events: Item[] = []; + client.on("message", data => events.push(JSON.parse(data.toString()))); + const until = async (condition: () => boolean) => { + const deadline = Date.now() + 3000; + while (!condition()) { if (Date.now() > deadline) throw new Error("WebSocket response timed out"); await new Promise(resolve => setTimeout(resolve, 5)); } + }; + try { + await new Promise((resolve, reject) => { client.once("open", resolve); client.once("error", reject); }); + client.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: [], tools: [collaborationTools(false)] })); + await until(() => events.some(event => event["type"] === "response.completed")); + assert.equal(parentAuth, "Bearer parent-ws-fixture"); + assert.equal(((parentRequests[0]!["tools"] as Item[])[0]!)!["name"], "subswitch_collaboration"); + const call = events.find(event => event["type"] === "response.output_item.done")?.["item"] as Item; + assert.equal(call["namespace"], "collaboration"); assert.deepEqual(call["encrypted_function_args"], []); + client.send(JSON.stringify({ type: "response.create", model: "sonnet", input: "first question" })); + await until(() => events.filter(event => event["type"] === "response.completed").length === 2); + const response = events.filter(event => event["type"] === "response.completed").at(-1)?.["response"] as Item; + client.send(JSON.stringify({ type: "response.create", previous_response_id: response["id"], input: [{ type: "message", role: "user", content: "second question" }] })); + await until(() => events.filter(event => event["type"] === "response.completed").length === 3); + assert.equal(parentRequests.length, 1); assert.equal(claude.requests.length, 2); + assert.equal(claude.requests[0]?.headers.authorization, "Bearer claude-ws-fixture"); + assert.match(claude.requests[1]!.body.toString(), /first reply/); assert.match(claude.requests[1]!.body.toString(), /second question/); + client.send(JSON.stringify({ type: "response.create", model: "sonnet", input: "third question" })); + await until(() => events.some(event => event["type"] === "error" && event["code"] === "rate_limit_error")); + const error = events.find(event => event["type"] === "error")!; + assert.equal(error["status"], 429); assert.equal(error["retry_after"], "23"); assert.match(String(error["message"]), /Retry-After: 23/); + assert.equal(parentRequests.length, 1, "Claude errors must not fall back to OpenAI"); + } finally { + client.terminate(); await proxy.close(); for (const ws of wss.clients) ws.terminate(); wss.close(); await close(server); await claude.close(); await rm(temp, { recursive: true, force: true }); + } + }); + + it("relays rejected upgrades and remains usable after the upstream closes", async () => { + const server = http.createServer(); + server.on("upgrade", (_req, socket) => socket.end('HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nRetry-After: 19\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}')); + const upstream = await listen(server); + const proxy = await startSubswitch({ codexIngress: { enabled: true, apiBaseUrl: upstream, claude: { enabled: true } } }); + const client = new WebSocket(`${proxy.url.replace("http:", "ws:")}/codex/v1/responses`); + try { + const result = await new Promise<{ status: number | undefined; retry: unknown }>((resolve, reject) => { + client.on("error", () => undefined); + client.once("open", () => reject(new Error("upgrade unexpectedly succeeded"))); + client.once("unexpected-response", (_req, response) => { response.resume(); response.once("end", () => resolve({ status: response.statusCode, retry: response.headers["retry-after"] })); }); + }); + assert.deepEqual(result, { status: 429, retry: "19" }); + assert.equal((await fetch(`${proxy.url}/__subswitch/health`)).status, 200); + } finally { client.terminate(); await proxy.close(); await close(server); } + }); + it("closes pending credential-bound upgrades without opening an upstream after shutdown", async () => { + const backend = http.createServer(); let connections = 0; + backend.on("connection", () => connections++); + const url = await listen(backend); + const config = loadConfig({ configPath: "fixture", readFile: () => JSON.stringify({ codexIngress: { enabled: true, subscriptionBaseUrl: url, claude: { enabled: true } } }) }); + assert.ok(config.ok); + let release!: (value: Result, ProxyError>) => void, started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + const credentials = new Promise, ProxyError>>(resolve => { release = resolve; }); + const auth = { refreshable: true, getCredentials: () => { started(); return credentials; }, forceRefresh: () => credentials }; + const gateway = new CodexGateway({ config: config.value.config, logger: { log() {} }, parentAuth: auth, resolveClaude: claudeResolver(config.value.config.codexIngress.claude.aliases), + claudeAuth: new ClaudeAuthManager({ store: createClaudeCredentialStore(config.value.config.codexIngress.claude), + oauthTokenUrl: config.value.config.codexIngress.claude.oauthTokenUrl, logger: { log() {} } }), + }); + const server = http.createServer(); + server.on("upgrade", (req, socket, head) => gateway.upgrade(req, socket, head, "subscription", "/responses")); + const local = await listen(server); + const client = new WebSocket(local.replace("http:", "ws:"), { headers: { "chatgpt-account-id": "fixture-account" } }); + client.on("error", () => undefined); + const ended = new Promise(resolve => client.once("close", () => resolve())); + try { + await requested; gateway.close(); + release({ ok: true, value: { provider: "codex", authHeaders: { authorization: "Bearer fixture-only", "chatgpt-account-id": "fixture-account" } } }); + await ended; assert.equal(connections, 0); + } finally { client.terminate(); gateway.close(); await close(server); await close(backend); } + }); +}); diff --git a/test/integration/reverse.test.ts b/test/integration/reverse.test.ts new file mode 100644 index 0000000..182ed59 --- /dev/null +++ b/test/integration/reverse.test.ts @@ -0,0 +1,176 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { startFakeUpstream, startSubswitch, makeAccessToken, makeAuthFileContent, type UpstreamHandler } from "./fake-upstreams.js"; +import type { Item } from "../../src/claude-adapter.js"; + +const sse = (content: Item[], stop = "end_turn") => { + const frames: Item[] = [{ type: "message_start", message: { type: "message", usage: { input_tokens: 10 } } }]; + content.forEach((block, index) => { + frames.push({ type: "content_block_start", index, content_block: block }); + frames.push({ type: "content_block_stop", index }); + }); + frames.push({ type: "message_delta", delta: { stop_reason: stop }, usage: { output_tokens: 5 } }, { type: "message_stop" }); + return frames.map(frame => `event: ${frame["type"]}\ndata: ${JSON.stringify(frame)}\n\n`).join(""); +}; + +async function setup(handler: UpstreamHandler, openaiHandler: UpstreamHandler = (_req, res, body) => res.end(body), overrides: Record = {}) { + const temp = await mkdtemp(join(tmpdir(), "subswitch-reverse-test-")); + const authFile = join(temp, "claude.json"); + await writeFile(authFile, JSON.stringify({ claudeAiOauth: { accessToken: "claude-private-fixture", expiresAt: Date.now() + 3600000 } }), { mode: 0o600 }); + const claude = await startFakeUpstream(handler), openai = await startFakeUpstream(openaiHandler); + const logs: unknown[] = []; + const proxy = await startSubswitch({ ...overrides, codexIngress: { enabled: true, apiBaseUrl: `${openai.url}/v1`, subscriptionBaseUrl: `${openai.url}/backend-api/codex`, + claude: { enabled: true, baseUrl: claude.url, authFile } } }, { logger: { log: (level, event, fields) => logs.push({ level, event, fields }) } }); + return { proxy, claude, openai, logs, close: async () => { await proxy.close(); await claude.close(); await openai.close(); await rm(temp, { recursive: true, force: true }); } }; +} +const readTool = { type: "function", name: "read", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } }; + +describe("production reverse HTTP ingress", () => { + it("routes Claude aliases, preserves thinking/tool results, and isolates credentials", async () => { + const fixture = await setup((_req, res, raw, index) => { + const body = JSON.parse(raw.toString()); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(index === 0 ? sse([ + { type: "thinking", thinking: "", signature: "signed-private-fixture" }, + { type: "tool_use", id: "toolu_read", name: body.tools[0].name, input: { path: "check.txt" } }, + ], "tool_use") : sse([{ type: "text", text: "read-value" }])); + }); + try { + const first = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer native-openai-fixture", "x-api-key": "incoming-other-secret" }, + body: JSON.stringify({ model: "sonnet", input: "Read check.txt", tools: [readTool], stream: false }) }); + assert.equal(first.status, 200); + const response = await first.json() as { id: string; output: Item[] }; + assert.equal(response.output[0]?.["type"], "reasoning"); assert.equal(response.output[1]?.["type"], "function_call"); + const second = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "sonnet", previous_response_id: response.id, input: [{ type: "function_call_output", call_id: "toolu_read", output: "read-value" }], stream: false }) }); + assert.equal(second.status, 200); assert.match(await second.text(), /read-value/); + assert.equal(fixture.openai.requests.length, 0); assert.equal(fixture.claude.requests.length, 2); + for (const request of fixture.claude.requests) { + assert.equal(request.headers.authorization, "Bearer claude-private-fixture"); assert.equal(request.headers["x-api-key"], undefined); + assert.equal(JSON.parse(request.body.toString()).model, "claude-sonnet-5"); + // MUTATION CHECK: deleting either identity field must fail this independent literal pin. + assert.equal(request.headers["anthropic-beta"], "claude-code-20250219,oauth-2025-04-20"); + assert.equal(JSON.parse(request.body.toString()).system[0].text, "You are Claude Code, Anthropic's official CLI for Claude."); + } + const continuation = JSON.parse(fixture.claude.requests[1]!.body.toString()); + assert.match(JSON.stringify(continuation.messages), /signed-private-fixture/); + assert.match(JSON.stringify(continuation.messages), /tool_result/); + assert.ok(!JSON.stringify(fixture.logs).includes("claude-private-fixture")); assert.ok(!JSON.stringify(fixture.logs).includes("signed-private-fixture")); + } finally { await fixture.close(); } + }); + + it("rejects null translated history items as client errors without contacting either upstream", async () => { + const fixture = await setup((_req, res) => res.end()); + try { + const response = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { + method: "POST", body: JSON.stringify({ model: "sonnet", input: [null] }), + }); + assert.equal(response.status, 400); assert.match(await response.text(), /invalid_input_item/); + assert.equal(fixture.claude.requests.length + fixture.openai.requests.length, 0); + } finally { await fixture.close(); } + }); + + it("keeps compressed ordinary OpenAI requests byte-identical", async () => { + const fixture = await setup((_req, res) => { res.writeHead(500); res.end(); }); + try { + const raw = gzipSync('{ "model": "gpt-5.5", "input": "unchanged", "stream":false }'); + const response = await fetch(`${fixture.proxy.url}/codex/v1/responses?x=%2f`, { method: "POST", headers: { "content-encoding": "gzip", authorization: "Bearer native-fixture" }, body: raw }); + assert.equal(response.status, 200); await response.arrayBuffer(); + assert.deepEqual(fixture.openai.requests[0]?.body, raw); assert.equal(fixture.openai.requests[0]?.headers["content-encoding"], "gzip"); + assert.equal(fixture.openai.requests[0]?.url, "/v1/responses?x=%2f"); assert.equal(fixture.claude.requests.length, 0); + } finally { await fixture.close(); } + }); + + it("preserves upstream 429 and Retry-After without billing or model fallback", async () => { + const fixture = await setup((_req, res) => { res.writeHead(429, { "content-type": "application/json", "retry-after": "17" }); res.end('{"error":{"type":"rate_limit_error","message":"Try later"}}'); }); + try { + const response = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", body: JSON.stringify({ model: "opus", input: "Hello" }) }); + assert.equal(response.status, 429); assert.equal(response.headers.get("retry-after"), "17"); + assert.match(await response.text(), /rate_limit_error/); assert.equal(fixture.claude.requests.length, 1); assert.equal(fixture.openai.requests.length, 0); + } finally { await fixture.close(); } + }); + + it("adds Claude discovery while preserving native catalog metadata", async () => { + const original = { slug: "gpt-5.5", custom_native_field: { preserve: true }, tool_mode: "code_mode_only", multi_agent_version: "v2" }; + const fixture = await setup((_req, res) => res.end(), (_req, res) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ models: [original], etag_hint: "unchanged" })); }); + try { + const response = await fetch(`${fixture.proxy.url}/codex/v1/models`); + const body = await response.json() as { models: Item[]; etag_hint: string }; + assert.deepEqual(body.models[0], original); assert.equal(body.etag_hint, "unchanged"); + assert.ok(body.models.some(model => model["slug"] === "sonnet")); assert.ok(body.models.some(model => model["slug"] === "claude-opus-5")); + assert.equal(fixture.claude.requests.length, 0); + } finally { await fixture.close(); } + }); + + it("fails missing continuation and translated compaction explicitly", async () => { + const fixture = await setup((_req, res) => res.end()); + try { + const missing = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", body: JSON.stringify({ model: "sonnet", input: [], previous_response_id: "unavailable" }) }); + assert.equal(missing.status, 409); assert.match(await missing.text(), /missing_continuation_state/); + const compact = await fetch(`${fixture.proxy.url}/codex/v1/responses/compact`, { method: "POST", body: JSON.stringify({ model: "sonnet", input: [] }) }); + assert.equal(compact.status, 400); assert.match(await compact.text(), /translated_compaction_unavailable/); + assert.equal(fixture.claude.requests.length + fixture.openai.requests.length, 0); + } finally { await fixture.close(); } + }); + + it("streams over-window OpenAI uploads while bounding translated Claude uploads", async () => { + const fixture = await setup((_req, res) => res.end(), undefined, { limits: { maxBufferedBodyBytes: 1024 } }); + try { + const raw = JSON.stringify({ model: "gpt-5.5", input: "x".repeat(8192) }); + const openai = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", body: raw }); + assert.equal(openai.status, 200); assert.equal(await openai.text(), raw); + assert.equal(fixture.openai.requests[0]?.body.toString(), raw); + const claude = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", body: JSON.stringify({ model: "sonnet", input: "x".repeat(8192) }) }); + assert.equal(claude.status, 413); await claude.text(); assert.equal(fixture.claude.requests.length, 0); + } finally { await fixture.close(); } + }); + + it("cancels the Claude upstream when the native HTTP client disconnects", async () => { + let closed!: () => void; + const disconnected = new Promise(resolve => { closed = resolve; }); + const fixture = await setup((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write('event: message_start\ndata: {"type":"message_start","message":{"type":"message","usage":{"input_tokens":1}}}\n\n'); + res.once("close", closed); + }); + try { + const controller = new AbortController(); + const response = await fetch(`${fixture.proxy.url}/codex/v1/responses`, { method: "POST", body: JSON.stringify({ model: "sonnet", input: "test", stream: true }), signal: controller.signal }); + const reader = response.body!.getReader(); await reader.read(); controller.abort(); await reader.cancel().catch(() => undefined); + let timer: ReturnType | undefined; + try { await Promise.race([disconnected, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("upstream was not cancelled")), 2000); })]); } + finally { clearTimeout(timer); } + } finally { await fixture.close(); } + }); + + it("binds locally supplied Codex credentials to the native account and refreshes once", async () => { + const temp = await mkdtemp(join(tmpdir(), "subswitch-native-auth-test-")); + const authFile = join(temp, "codex.json"), account = "acct_integration_1"; + const oldToken = makeAccessToken(Date.now() + 3600000, account), newToken = makeAccessToken(Date.now() + 7200000, account); + await writeFile(authFile, makeAuthFileContent(oldToken), { mode: 0o600 }); + const upstream = await startFakeUpstream((req, res, body) => { + res.setHeader("content-type", "application/json"); + if (req.url === "/token") { res.end(JSON.stringify({ access_token: newToken, refresh_token: "new-fixture-refresh" })); return; } + if (req.headers.authorization === `Bearer ${oldToken}`) { res.writeHead(401); res.end('{"error":{"message":"expired"}}'); return; } + res.end(body); + }); + const proxy = await startSubswitch({ providers: { codex: { authFile, oauthTokenUrl: `${upstream.url}/token` } }, + codexIngress: { enabled: true, subscriptionBaseUrl: `${upstream.url}/v1`, apiBaseUrl: `${upstream.url}/v1`, claude: { enabled: true } } }); + try { + const response = await fetch(`${proxy.url}/codex/backend-api/codex/responses`, { method: "POST", headers: { "chatgpt-account-id": account }, body: '{"model":"gpt-5.5","input":"hello"}' }); + assert.equal(response.status, 200); await response.text(); + const inference = upstream.requests.filter(request => request.url === "/v1/responses"); + assert.equal(inference.length, 2); assert.equal(inference[1]?.headers.authorization, `Bearer ${newToken}`); + assert.deepEqual(inference[0]?.body, inference[1]?.body); + const count = upstream.requests.length; + const mismatch = await fetch(`${proxy.url}/codex/backend-api/codex/responses`, { method: "POST", headers: { "chatgpt-account-id": "different-account" }, body: '{"model":"gpt-5.5","input":"hello"}' }); + assert.equal(mismatch.status, 401); assert.match(await mismatch.text(), /codex_account_mismatch/); assert.equal(upstream.requests.length, count); + const api = await fetch(`${proxy.url}/codex/v1/responses`, { method: "POST", headers: { "chatgpt-account-id": account }, body: '{"model":"gpt-5.5","input":"hello"}' }); + await api.text(); assert.equal(upstream.requests.at(-1)?.headers.authorization, undefined, "API mode must not use a subscription credential"); + } finally { await proxy.close(); await upstream.close(); await rm(temp, { recursive: true, force: true }); } + }); +}); diff --git a/test/integration/review-transport.test.ts b/test/integration/review-transport.test.ts new file mode 100644 index 0000000..461f28e --- /dev/null +++ b/test/integration/review-transport.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; +import { rawHttpRequest, startFakeUpstream, startSubswitch } from "./fake-upstreams.js"; + +describe("shared transport review controls", () => { + it("strips Connection-nominated headers in both directions while retaining provider headers", async () => { + const upstream = await startFakeUpstream((_req, res) => { + res.writeHead(200, ["Connection", "close, X-Origin-Probe", "X-Origin-Probe", "local", "anthropic-beta", "origin-beta"]); + res.end("unchanged"); + }); + const proxy = await startSubswitch({ anthropic: { baseUrl: upstream.url }, + codexIngress: { enabled: true, apiBaseUrl: upstream.url } }); + try { + for (const path of ["/v1/messages", "/codex/v1/responses"]) { + const response = await rawHttpRequest(proxy.url + path, { method: "POST", body: Buffer.from('{"model":"foreign"}'), + rawHeaders: ["Connection", "keep-alive, X-Relay-Probe", "X-Relay-Probe", "local", "anthropic-beta", "fixture-beta"] }); + assert.equal(response.status, 200); assert.equal(response.body.toString(), "unchanged"); + const headers = response.rawHeaders.map(value => value.toLowerCase()); + assert.ok(!headers.includes("x-origin-probe")); assert.ok(headers.includes("origin-beta")); + } + for (const request of upstream.requests) { + assert.equal(request.headers["x-relay-probe"], undefined); + assert.equal(request.headers["anthropic-beta"], "fixture-beta"); + } + assert.equal(upstream.requests.length, 2); + } finally { await proxy.close(); await upstream.close(); } + }); + + for (const translated of [false, true]) it(`bounds upgraded sockets with Claude routing ${translated ? "enabled" : "disabled"}`, async () => { + const backend = http.createServer(); + const wss = new WebSocketServer({ server: backend }); + let connections = 0; + wss.on("connection", () => connections++); + await new Promise(resolve => backend.listen(0, "127.0.0.1", resolve)); + const upstream = `http://127.0.0.1:${(backend.address() as AddressInfo).port}`; + const proxy = await startSubswitch({ codexIngress: { enabled: true, apiBaseUrl: upstream, + maxUpstreamSockets: 1, claude: { enabled: translated } } }); + const url = proxy.url.replace("http:", "ws:") + "/codex/v1/responses"; + const first = new WebSocket(url); + let second: WebSocket | undefined; + try { + await once(first, "open"); + second = new WebSocket(url); const secondOpen = once(second, "open"); + await new Promise(resolve => setTimeout(resolve, 40)); + assert.equal(connections, 1, "a pending client must not open another upstream socket"); + first.close(); await secondOpen; + assert.equal(connections, 2); + } finally { + first.terminate(); second?.terminate(); await proxy.close(); + for (const client of wss.clients) client.terminate(); wss.close(); + await new Promise(resolve => backend.close(() => resolve())); + } + }); +}); diff --git a/test/unit/claude-auth.test.ts b/test/unit/claude-auth.test.ts new file mode 100644 index 0000000..10d3ff7 --- /dev/null +++ b/test/unit/claude-auth.test.ts @@ -0,0 +1,55 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ClaudeAuthManager, claudeCredentialLocation, type ClaudeCredentialStore } from "../../src/claude-auth.js"; + +const logger = { log() {} }; +const record = (expiresAt: number, accessToken = "fixture-access", refreshToken = "fixture-refresh") => JSON.stringify({ + unrelated: { keep: true }, claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes: ["user:inference"], extra: "preserve" }, +}); +describe("Claude subscription credentials", () => { + it("respects explicit files and native configuration-directory stores", () => { + assert.equal(claudeCredentialLocation({ authFile: "/tmp/fixture.json" }, {}, "darwin").kind, "file"); + assert.deepEqual(claudeCredentialLocation({}, {}, "darwin"), { kind: "keychain", service: "Claude Code-credentials" }); + assert.notDeepEqual(claudeCredentialLocation({ configDir: "/tmp/other" }, {}, "darwin"), claudeCredentialLocation({}, {}, "darwin")); + assert.deepEqual(claudeCredentialLocation({ configDir: "/tmp/config" }, {}, "linux"), { kind: "file", path: "/tmp/config/.credentials.json" }); + }); + it("uses valid native credentials without refreshing or switching authentication modes", async () => { + let requests = 0; + const auth = new ClaudeAuthManager({ store: { read: async () => record(9999999), write: async () => assert.fail("unexpected write") }, + oauthTokenUrl: "http://localhost/token", logger, now: () => 1000, fetchImpl: async () => { requests++; throw new Error(); } }); + assert.deepEqual(await auth.getCredentials(), { ok: true, value: { provider: "claude", authHeaders: { authorization: "Bearer fixture-access" } } }); + assert.equal(requests, 0); + }); + it("single-flights refresh and preserves unknown credential fields", async () => { + let raw = record(0), requests = 0, writes = 0; + const store: ClaudeCredentialStore = { read: async () => raw, write: async value => { writes++; raw = value; } }; + const auth = new ClaudeAuthManager({ store, oauthTokenUrl: "http://localhost/token", logger, now: () => 1000, + fetchImpl: async () => { requests++; await new Promise(resolve => setTimeout(resolve, 10)); return new Response(JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 })); } }); + const results = await Promise.all(Array.from({ length: 12 }, () => auth.getCredentials())); + assert.ok(results.every(result => result.ok)); assert.equal(requests, 1); assert.equal(writes, 1); + const saved = JSON.parse(raw); assert.deepEqual(saved.unrelated, { keep: true }); assert.equal(saved.claudeAiOauth.extra, "preserve"); + assert.equal(saved.claudeAiOauth.refreshToken, "new-refresh"); + }); + it("adopts external rotation after rejection without overwriting native credentials", async () => { + let raw = record(0), writes = 0; + const auth = new ClaudeAuthManager({ store: { read: async () => raw, write: async () => { writes++; } }, oauthTokenUrl: "http://localhost/token", logger, now: () => 1000, + fetchImpl: async () => { raw = record(9999999, "native-rotated", "native-refresh"); return new Response('{"error":"invalid_grant"}', { status: 400 }); } }); + const result = await auth.getCredentials(); assert.ok(result.ok); + assert.equal(result.value.authHeaders["authorization"], "Bearer native-rotated"); assert.equal(writes, 0); + }); + it("fails credential persistence without exposing the refreshed secret", async () => { + const auth = new ClaudeAuthManager({ store: { read: async () => record(0), write: async () => { throw new Error("fixture-secret"); } }, + oauthTokenUrl: "http://localhost/token", logger, now: () => 1000, + fetchImpl: async () => new Response('{"access_token":"fixture-secret","refresh_token":"new-refresh","expires_in":3600}') }); + const result = await auth.getCredentials(); assert.equal(result.ok, false); assert.ok(!JSON.stringify(result).includes("fixture-secret")); + }); + it("adopts a native refresh between inference and the forced-refresh request", async () => { + let raw = record(9999999), requests = 0; + const auth = new ClaudeAuthManager({ store: { read: async () => raw, write: async () => assert.fail("unexpected write") }, + oauthTokenUrl: "http://localhost/token", logger, now: () => 1000, fetchImpl: async () => { requests++; throw new Error(); } }); + assert.ok((await auth.getCredentials()).ok); + raw = record(9999999, "native-new", "native-refresh-new"); + const refreshed = await auth.forceRefresh(); assert.ok(refreshed.ok); + assert.equal(refreshed.value.authHeaders["authorization"], "Bearer native-new"); assert.equal(requests, 0); + }); +}); diff --git a/test/unit/claude-credential-store.test.ts b/test/unit/claude-credential-store.test.ts new file mode 100644 index 0000000..1490cd0 --- /dev/null +++ b/test/unit/claude-credential-store.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, stat, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { createClaudeCredentialStore, type ClaudeStoreDeps } from "../../src/claude-auth.js"; + +describe("native Claude credential persistence", () => { + it("atomically replaces a credential file with private permissions and no temporary residue", async () => { + const directory = await mkdtemp(join(tmpdir(), "subswitch-store-")); + try { + const path = join(directory, "credentials.json"); + await writeFile(path, '{"old":true}', { mode: 0o644 }); + const store = createClaudeCredentialStore({ authFile: path }); + assert.equal(await store.read(), '{"old":true}'); + await store.write('{"new":"private fixture"}'); + assert.equal(await store.read(), '{"new":"private fixture"}'); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.deepEqual(await readdir(directory), ["credentials.json"]); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + const keychain = (account: string, persist = true) => { + let stored = '{"old":true}', commands = "", spawned = 0; + const deps: ClaudeStoreDeps = { + platform: "darwin", env: {}, + exec: async (file, args) => { + assert.equal(file, "security"); + return { stdout: args.includes("-w") ? Buffer.from(stored).toString("hex") : `"acct"=${JSON.stringify(account)}`, stderr: "" }; + }, + spawn: (file, args) => { + assert.equal(file, "security"); assert.deepEqual(args, ["-i"]); spawned++; + const child = Object.assign(new EventEmitter(), { stdin: new PassThrough(), stderr: new PassThrough(), kill() { return true; } }); + child.stdin.on("data", chunk => { commands += chunk.toString(); }); + child.stdin.on("finish", () => { + if (persist) stored = Buffer.from(commands.trim().split(" -X ")[1]!, "hex").toString("utf8"); + child.emit("close", 0); + }); + return child; + }, + }; + return { store: createClaudeCredentialStore({}, deps), commands: () => commands, spawned: () => spawned }; + }; + + it("sends keychain secrets through hex stdin and verifies the stored value", async () => { + const fixture = keychain("fixture-account"); + assert.equal(await fixture.store.read(), '{"old":true}'); + await fixture.store.write('{"token":"private-fixture-😀"}'); + assert.equal(await fixture.store.read(), '{"token":"private-fixture-😀"}'); + assert.ok(!fixture.commands().includes("private-fixture")); + assert.match(fixture.commands(), /^add-generic-password -U -s "Claude Code-credentials" -a "fixture-account" -X [a-f0-9]+\n$/); + }); + + it("rejects interactive-parser injection before spawning security", async () => { + for (const account of ['bad"account', "bad\\account", "bad\naccount"]) { + const fixture = keychain(account); + await assert.rejects(fixture.store.write("fixture"), /keychain_account_unavailable/); + assert.equal(fixture.spawned(), 0); + } + }); + + it("rejects exit-zero keychain failures when read-back does not match", async () => { + const fixture = keychain("fixture-account", false); + await assert.rejects(fixture.store.write('{"new":true}'), /keychain_write_failed/); + assert.equal(fixture.spawned(), 1); + }); +}); diff --git a/test/unit/claude-handler.test.ts b/test/unit/claude-handler.test.ts new file mode 100644 index 0000000..d49b6e4 --- /dev/null +++ b/test/unit/claude-handler.test.ts @@ -0,0 +1,84 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ClaudeHandler, ClaudeHttpError } from "../../src/claude-handler.js"; +import type { ClaudeAuth } from "../../src/claude-auth.js"; +import { loadConfig } from "../../src/config.js"; +import { ok, err } from "../../src/result.js"; + +const config = () => { + const loaded = loadConfig({ configPath: "/fixture.json", readFile: () => "{}", env: {} }); + assert.ok(loaded.ok); return loaded.value.config.codexIngress.claude; +}; +const credential = (token: string) => ok({ provider: "claude" as const, authHeaders: { authorization: `Bearer ${token}` } }); +const success = () => new Response([ + { type: "message_start", message: { type: "message" } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "done" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, +].map(event => `data: ${JSON.stringify(event)}\n\n`).join("")); +const collect = async (handler: ClaudeHandler, signal = new AbortController().signal) => { + const events = []; + for await (const event of handler.respond({ model: "claude-sonnet-5", input: "hello" }, signal)) events.push(event); + return events; +}; + +describe("Claude subscription request controls", () => { + it("reports relay credential unavailability without asking native Codex to refresh OpenAI", async () => { + const handler = new ClaudeHandler(config(), { + getCredentials: async () => err({ kind: "auth", message: "Sign in with Claude Code." }), + forceRefresh: async () => assert.fail("unexpected refresh"), + }, { log() {} }, async () => assert.fail("unavailable credentials must not reach inference")); + await assert.rejects(collect(handler), error => error instanceof ClaudeHttpError && error.status === 503 && + error.code === "claude_auth_unavailable" && error.message === "Sign in with Claude Code."); + }); + + // MUTATION CHECK: disabling the 401 refresh branch must fail both retry assertions. + it("refreshes a rejected token once and pins subscription identity independently", async () => { + let refreshes = 0; + const auth: ClaudeAuth = { getCredentials: async () => credential("old-token"), forceRefresh: async () => { + refreshes++; return credential("new-token"); + } }; + const tokens: (string | null)[] = []; + const handler = new ClaudeHandler(config(), auth, { log() {} }, async (_url, options) => { + const headers = new Headers(options?.headers); + tokens.push(headers.get("authorization")); + assert.equal(headers.get("anthropic-beta"), "claude-code-20250219,oauth-2025-04-20"); + assert.equal(JSON.parse(String(options?.body)).system[0].text, "You are Claude Code, Anthropic's official CLI for Claude."); + return tokens.length === 1 ? new Response("unauthorized", { status: 401 }) : success(); + }); + assert.equal((await collect(handler)).at(-1)?.["type"], "response.completed"); + assert.deepEqual(tokens, ["Bearer old-token", "Bearer new-token"]); + assert.equal(refreshes, 1); + }); + + it("stops after a second 401 without another refresh or fallback", async () => { + let refreshes = 0, requests = 0; + const handler = new ClaudeHandler(config(), { + getCredentials: async () => credential("old-token"), forceRefresh: async () => { refreshes++; return credential("new-token"); }, + }, { log() {} }, async () => { requests++; return new Response("unauthorized", { status: 401 }); }); + await assert.rejects(collect(handler), error => error instanceof ClaudeHttpError && error.status === 401); + assert.equal(requests, 2); assert.equal(refreshes, 1); + }); + + it("reports a request timeout as 504 and aborts the fetch", async () => { + const handler = new ClaudeHandler({ ...config(), requestTimeoutMs: 25 }, { + getCredentials: async () => credential("fixture"), forceRefresh: async () => assert.fail("unexpected refresh"), + }, { log() {} }, async (_url, options) => new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }); + })); + await assert.rejects(collect(handler), error => error instanceof ClaudeHttpError && error.status === 504 && error.code === "claude_timeout"); + }); + + it("aborts a stalled stream with 504 and cancels its reader", async () => { + let cancelled = false; + const handler = new ClaudeHandler({ ...config(), streamIdleTimeoutMs: 25 }, { + getCredentials: async () => credential("fixture"), forceRefresh: async () => assert.fail("unexpected refresh"), + }, { log() {} }, async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('data: {"type":"message_start","message":{"type":"message"}}\n\n')); }, + cancel() { cancelled = true; }, + }))); + await assert.rejects(collect(handler), error => error instanceof ClaudeHttpError && error.status === 504); + assert.equal(cancelled, true); + }); +}); diff --git a/test/unit/claude-stream.test.ts b/test/unit/claude-stream.test.ts new file mode 100644 index 0000000..02ffc0d --- /dev/null +++ b/test/unit/claude-stream.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { translateClaudeStream } from "../../src/claude-stream.js"; +import { reverseRequest, ReverseContractError, type Item } from "../../src/claude-adapter.js"; +import { ReverseState } from "../../src/claude-state.js"; + +const request = reverseRequest({ model: "claude-sonnet-5", input: "Read a fixture", tools: [ + { type: "function", name: "read", parameters: { type: "object", properties: { path: { type: "string" } } } }, +] }); +const wire = [...request.tools.keys()][0]!; +const frames = async function* (events: Item[]) { for (const event of events) yield { event: String(event["type"]), data: JSON.stringify(event) }; }; +const start = { type: "message_start", message: { type: "message", usage: { input_tokens: 10 } } }; +const terminal = (reason = "end_turn") => [{ type: "message_delta", delta: { stop_reason: reason }, usage: { output_tokens: 5 } }, { type: "message_stop" }]; +const options = () => ({ id: "resp_test", model: "claude-sonnet-5", request, state: new ReverseState(), maxBytes: 65536 }); +const gather = async (events: Item[]) => { + const output: Item[] = []; + for await (const event of translateClaudeStream(frames(events), options())) output.push(event); + return output; +}; + +describe("Claude streaming translation", () => { + it("can continue cancelled output using readable history without replaying unfinished thinking", async () => { + const config = options(); + const emitted: Item[] = []; + for await (const event of translateClaudeStream(frames([ + start, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "private unfinished", signature: "unfinished-signature" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "partial answer" } }, + ]), config)) { + emitted.push(event); + if (event["type"] === "response.output_text.delta") break; + } + const reasoning = emitted.find(event => event["type"] === "response.output_item.done")?.["item"] as Item; + const notice = "\nThe previous turn was interrupted on purpose. Tools may have partially executed.\n"; + const next = reverseRequest({ model: "claude-sonnet-5", input: [ + { role: "user", content: "Original question" }, reasoning, + { role: "assistant", content: [{ type: "output_text", text: "partial answer" }] }, + { role: "developer", content: [{ type: "input_text", text: notice }] }, + { role: "user", content: "Continue" }, + ] }, [], config.state); + assert.deepEqual(next.body["messages"], [ + { role: "user", content: [{ type: "text", text: "Original question" }] }, + { role: "assistant", content: [{ type: "text", text: "partial answer" }] }, + { role: "user", content: [{ type: "text", text: notice }, { type: "text", text: "Continue" }] }, + ]); + assert.ok(!JSON.stringify(next.body).includes("unfinished")); + assert.throws(() => reverseRequest({ model: "claude-sonnet-5", input: [ + { role: "user", content: "Original" }, { role: "developer", content: "Arbitrary new instructions" }, + ] }, [], config.state), /mid_history_instructions_unimplemented/); + assert.throws(() => new ReverseState().open(String(reasoning["encrypted_content"])), /invalid_opaque_state/); + }); + + it("emits text before upstream completion with stable item identities", async () => { + let release!: () => void; + const pending = new Promise(resolve => { release = resolve; }); + const source = async function* () { + yield* frames([start, { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello ü" } }]); + await pending; + yield* frames([{ type: "content_block_stop", index: 0 }, ...terminal()]); + }; + const stream = translateClaudeStream(source(), options()); + const output: Item[] = []; + while (true) { const event = await stream.next(); assert.equal(event.done, false); output.push(event.value!); if (event.value?.["type"] === "response.output_text.delta") break; } + release(); for await (const event of stream) output.push(event); + const delta = output.find(event => event["type"] === "response.output_text.delta")!; + const final = output.find(event => event["type"] === "response.output_text.done")!; + assert.equal(delta["item_id"], final["item_id"]); assert.equal(final["text"], "Hello ü"); + assert.equal(output.at(-1)?.["type"], "response.completed"); + assert.deepEqual(output.map(event => event["sequence_number"]), output.map((_, index) => index)); + }); + + it("commits valid tools only after message_stop and preserves thinking signatures", async () => { + const output = await gather([start, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "private fixture" } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "signed-fixture" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_fixture", name: wire, input: {} } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"path":' } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '"a.txt"}' } }, + { type: "content_block_stop", index: 1 }, ...terminal("tool_use")]); + assert.equal(output.find(event => event["type"] === "response.function_call_arguments.done")?.["arguments"], '{"path":"a.txt"}'); + assert.ok(!JSON.stringify(output).includes("signed-fixture")); + assert.ok(!JSON.stringify(output).includes("private fixture")); + const final = output.at(-1)?.["response"] as Item; + assert.equal((final["output"] as Item[])[0]?.["type"], "reasoning"); + }); + + it("does not emit an executable call from truncated or malformed streams", async () => { + const prefix = [start, { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_fixture", name: wire, input: {} } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"path":"a.txt"}' } }, + { type: "content_block_stop", index: 0 }]; + const output: Item[] = []; + await assert.rejects(async () => { for await (const event of translateClaudeStream(frames(prefix), options())) output.push(event); }, + error => error instanceof ReverseContractError && error.code === "missing_claude_terminal"); + assert.ok(!output.some(event => event["type"] === "response.function_call_arguments.done" || + (event["type"] === "response.output_item.done" && ["function_call", "custom_tool_call"].includes(String((event["item"] as Item)?.["type"]))))); + await assert.rejects(() => gather([start, { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }]), + error => error instanceof ReverseContractError && error.code === "invalid_claude_block_index"); + }); + it("reports output limits without committing tools and keeps streamed text identities", async () => { + const output = await gather([start, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "before" } }, { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_limit", name: wire, input: {} } }, { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "after" } }, { type: "content_block_stop", index: 2 }, + ...terminal("max_tokens")]); + assert.equal(output.at(-1)?.["type"], "response.incomplete"); + assert.ok(!output.some(event => event["type"] === "response.function_call_arguments.done")); + const added = output.filter(event => event["type"] === "response.output_item.added" && (event["item"] as Item)["type"] === "message"); + const done = output.filter(event => event["type"] === "response.output_item.done" && (event["item"] as Item)["type"] === "message"); + assert.deepEqual(added.map(event => [(event["item"] as Item)["id"], event["output_index"]]), done.map(event => [(event["item"] as Item)["id"], event["output_index"]])); + }); +}); diff --git a/test/unit/codex-doctor.test.ts b/test/unit/codex-doctor.test.ts new file mode 100644 index 0000000..77b6a9f --- /dev/null +++ b/test/unit/codex-doctor.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runCodexDoctor } from "../../src/codex-doctor.js"; +import { loadConfig } from "../../src/config.js"; + +describe("Codex doctor", () => { + it("continues independent diagnostics after credential, health, or TLS failures", async () => { + const loaded = loadConfig({ configPath: "fixture", readFile: () => '{"codexIngress":{"enabled":true,"claude":{"enabled":true}}}' }); + assert.ok(loaded.ok); + const output: string[] = []; + const result = await runCodexDoctor(loaded.value.config, line => output.push(line), { + env: { CODEX_HOME: "/fixture/native" }, project: "/fixture/project", color: true, + read: async () => null, auth: async () => { throw new Error("private fixture"); }, + httpGet: async () => { throw new Error("private fixture"); }, tlsConnect: async () => { throw new Error("private fixture"); }, + }); + assert.equal(result, 1); + const text = output.join("\n"); + assert.match(text, /Claude subscription/); assert.match(text, /Claude connectivity/); assert.match(text, /Codex setup/); + assert.match(text, /check\(s\) failed/); assert.ok(!text.includes("private fixture")); assert.match(text, /\u001b\[/); + }); + it("rejects an unversioned health claim and still checks healthy sibling role files", async () => { + const loaded = loadConfig({ configPath: "fixture", readFile: () => '{"codexIngress":{"enabled":true,"claude":{"enabled":true}}}' }); + assert.ok(loaded.ok); + const output: string[] = []; + await runCodexDoctor(loaded.value.config, line => output.push(line), { + env: { CODEX_HOME: "/fixture/native" }, project: "/fixture/project", + read: async path => path === "/fixture/native/config.toml" ? '[agents.broken]\nconfig_file="broken.toml"\n[agents.working]\nmodel="sonnet"' : + path === "/fixture/native/broken.toml" ? 'model="unfinished' : null, + auth: async () => ({ available: true, expired: true, refreshable: true }), + httpGet: async () => ({ ok: true, status: 200, body: '{"codexIngress":{"translationAvailable":true}}' }), + tlsConnect: async () => ({ kind: "reachable" }), + }); + const text = output.join("\n"); + assert.match(text, /start subswitch serve/); assert.match(text, /agent broken:.*FAIL/); + assert.match(text, /agent working: sonnet → claude-sonnet-5/); assert.match(text, /refresh required/); + }); + it("checks routing, auth, native setup and configured agent model files without refreshing", async () => { + const loaded = loadConfig({ configPath: "fixture", readFile: () => '{"providers":{"codex":{"authFile":"/fixture/native/auth.json"}},"codexIngress":{"enabled":true,"claude":{"enabled":true}}}' }); + assert.ok(loaded.ok); + const output: string[] = []; + const result = await runCodexDoctor(loaded.value.config, line => output.push(line), { + env: { CODEX_HOME: "/fixture/native" }, project: "/fixture/project", + read: async path => path === "/fixture/native/config.toml" ? 'openai_base_url = "http://127.0.0.1:4141/codex/backend-api/codex"\n[agents.worker]\nconfig_file = "worker.toml"' : + path === "/fixture/native/worker.toml" ? 'model = "sonnet"' : path === "/fixture/native/auth.json" ? + '{"tokens":{"access_token":"fixture-access","refresh_token":"fixture-refresh","account_id":"fixture-account"}}' : null, + auth: async () => ({ available: true, expired: false, refreshable: true }), + httpGet: async () => ({ ok: true, status: 200, body: '{"codexIngress":{"schemaVersion":1,"enabled":true,"mode":"model-routing","translationAvailable":true,"credentials":"client","transports":["http","websocket"]}}' }), + tlsConnect: async () => ({ kind: "reachable" }), + }); + assert.equal(result, 0); assert.match(output.join("\n"), /agent worker: sonnet → claude-sonnet-5/); + }); + it("fails missing credentials, disabled routing, and unavailable native setup", async () => { + const loaded = loadConfig({ configPath: "fixture", readFile: () => "{}" }); assert.ok(loaded.ok); + const output: string[] = []; + const result = await runCodexDoctor(loaded.value.config, line => output.push(line), { + env: { CODEX_HOME: "/fixture/native" }, project: "/fixture/project", read: async () => null, + auth: async () => ({ available: false, expired: false, refreshable: false }), + httpGet: async () => ({ ok: false, connectionRefused: true }), tlsConnect: async () => ({ kind: "reachable" }), + }); + assert.equal(result, 1); assert.match(output.join("\n"), /sign in with Claude Code/); assert.match(output.join("\n"), /start subswitch serve/); + }); +}); diff --git a/test/unit/codex-init.test.ts b/test/unit/codex-init.test.ts new file mode 100644 index 0000000..c54e406 --- /dev/null +++ b/test/unit/codex-init.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { planCodexEndpoint as planEndpointResult, planCodexSetup as planSetupResult } from "../../src/codex-init.js"; +import { loadConfig, mergeConfigObjects } from "../../src/config.js"; +import type { InitFsDeps } from "../../src/init.js"; +import { resolve } from "node:path"; + +const unwrap = (result: import("../../src/result.js").Result): T => { + if (!result.ok) throw new Error(result.error.message); + return result.value; +}; +const planCodexEndpoint = (...args: Parameters) => unwrap(planEndpointResult(...args)); +const planCodexSetup = async (...args: Parameters) => unwrap(await planSetupResult(...args)); + +describe("Codex setup parity", () => { + it("changes only the root endpoint value, preserving native settings and comments", () => { + const original = '# comment\nmodel = "gpt-6-astra"\n"openai_base_url" = \'https://chatgpt.com/backend-api/codex\' # preserve\n[agents.worker]\nmodel = "sonnet"\n'; + const result = planCodexEndpoint(original, 4141); + assert.equal(result.content, original.replace("'https://chatgpt.com/backend-api/codex'", '"http://127.0.0.1:4141/codex/backend-api/codex"')); + assert.equal(planCodexEndpoint(result.content, 4141).content, result.content); + }); + it("does not confuse multiline string content or nested keys with root settings", () => { + const original = 'developer_instructions = """\nopenai_base_url = \'example\'\n[not_a_table]\n"""\n[model_providers.example]\nopenai_base_url = "nested"\n'; + const result = planCodexEndpoint(original, 4142); + assert.equal(result.content, 'openai_base_url = "http://127.0.0.1:4142/codex/backend-api/codex"\n' + original); + assert.throws(() => planCodexEndpoint('model_provider = "custom"\n', 4141), /custom model_provider/); + assert.throws(() => planCodexEndpoint('openai_base_url = "unfinished', 4141), /Cannot parse Codex/); + }); + it("plans all writes first and preserves a previously selected upstream", async () => { + const files = new Map([ + ["/native/config.toml", 'openai_base_url = "https://trusted.example/v1"\nmodel = "gpt-6-astra"\n'], + ["/global/config.json", '{"codexIngress":{"allowInsecureBaseUrl":true,"claude":{"aliases":{"worker":"claude-sonnet-5"}}}}'], + ]); + const fs: InitFsDeps = { readFile: async path => files.get(path) ?? null, exists: path => files.has(path), writeFile: async () => assert.fail("planning wrote files") }; + const plans = await planCodexSetup({ client: "codex", port: 4141, settingsTarget: "local" }, + { codexConfig: "/native/config.toml", subswitchConfig: "/global/config.json", project: "/project" }, fs); + assert.equal(plans.length, 2); + const global = JSON.parse(plans[0]!.content); + assert.equal(global.codexIngress.subscriptionBaseUrl, "https://trusted.example/v1"); + assert.equal(global.codexIngress.allowInsecureBaseUrl, true); assert.equal(global.codexIngress.claude.enabled, true); + assert.deepEqual(global.codexIngress.claude.aliases, { worker: "claude-sonnet-5" }); + assert.ok(!plans[1]!.preview.includes('model =')); + }); + it("does not overwrite reverse configuration when both clients use the same config file", async () => { + const fs: InitFsDeps = { readFile: async () => null, exists: () => false, writeFile: async () => assert.fail() }; + const plans = await planCodexSetup({ client: "all", port: 4141, settingsTarget: "local" }, + { codexConfig: "/native/config.toml", subswitchConfig: "/project/subswitch.config.json", project: "/project" }, fs); + assert.equal(plans.filter(plan => plan.path === "/project/subswitch.config.json").length, 1); + assert.equal(JSON.parse(plans[0]!.content).codexIngress.claude.enabled, true); + assert.equal(plans.at(-1)?.path, "/project/.claude/settings.local.json"); + }); + it("deduplicates relative and absolute references to the same configuration", async () => { + const fs: InitFsDeps = { readFile: async path => path === "/native/config.toml" ? 'openai_base_url = "https://trusted.example/v1"' : path.endsWith('subswitch.config.json') ? '{"codexIngress":{"allowInsecureBaseUrl":true}}' : null, + exists: () => false, writeFile: async () => assert.fail() }; + const plans = await planCodexSetup({ client: "all", port: 4141, settingsTarget: "local" }, + { codexConfig: "/native/config.toml", subswitchConfig: "./subswitch.config.json", project: process.cwd() }, fs); + const configWrites = plans.filter(plan => resolve(plan.path) === resolve("subswitch.config.json")); + assert.equal(configWrites.length, 1); + assert.equal(JSON.parse(configWrites[0]!.content).codexIngress.subscriptionBaseUrl, "https://trusted.example/v1"); + }); +}); + +describe("user configuration fallback", () => { + it("uses injected readers and paths for both sources and reports their provenance", () => { + const reads: string[] = []; + const result = loadConfig({ env: {}, homeDir: "/fixture/home", cwd: "/fixture/project", readFile: path => { + reads.push(path); + return path.includes("/.config/") ? '{"port":4142,"codexIngress":{"enabled":true}}' : '{"port":4143}'; + } }); + assert.ok(result.ok); + assert.equal(result.value.config.port, 4143); + assert.equal(result.value.config.codexIngress.enabled, true); + assert.deepEqual(result.value.configPaths, ["/fixture/home/.config/subswitch/config.json", "/fixture/project/subswitch.config.json"]); + assert.equal(reads.length, 2); + }); + it("attributes a legacy user-config key to the user file", () => { + const result = loadConfig({ env: {}, globalConfigPath: "/fixture/user.json", cwd: "/fixture/project", + readFile: path => path === "/fixture/user.json" ? '{"codex":{}}' : "{}" }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error.message, /unsupported config keys in \/fixture\/user.json/); + assert.ok(!result.error.message.includes("/fixture/project")); + } + }); + it("requires explicit custom-host trust before planning setup writes", async () => { + const result = await planSetupResult({ client: "codex", port: 4141, settingsTarget: "local" }, + { codexConfig: "/native/config.toml", subswitchConfig: "/global/config.json", project: "/project" }, + { readFile: async path => path.endsWith(".toml") ? 'openai_base_url = "https://custom.example/v1"' : null, + exists: () => false, writeFile: async () => assert.fail("planning wrote files") }); + assert.equal(result.ok, false); + if (!result.ok) assert.match(result.error.message, /custom.example.*allowInsecureBaseUrl.*global\/config.json/); + }); + it("merges project settings over user defaults without losing sibling fields", () => { + const result = loadConfig({ env: {}, globalConfigPath: "/fixture/global.json", readFile: path => path === "/fixture/global.json" ? + '{"port":4142,"codexIngress":{"enabled":true,"claude":{"enabled":true,"aliases":{"worker":"claude-sonnet-5"}}}}' : + '{"codexIngress":{"allowInsecureBaseUrl":true,"claude":{"aliases":{"second":"claude-opus-5"}}}}' }); + assert.ok(result.ok); assert.equal(result.value.config.port, 4142); assert.equal(result.value.config.codexIngress.claude.enabled, true); + assert.deepEqual(result.value.config.codexIngress.claude.aliases, { worker: "claude-sonnet-5", second: "claude-opus-5" }); + }); + it("keeps explicit configuration authoritative and preserves own-property semantics", () => { + const reads: string[] = []; + const result = loadConfig({ configPath: "/explicit.json", globalConfigPath: "/ignored.json", env: {}, readFile: path => { reads.push(path); return "{}"; } }); + assert.ok(result.ok); assert.deepEqual(reads, ["/explicit.json"]); + const merged = mergeConfigObjects({}, JSON.parse('{"__proto__":{"injected":true}}')); + assert.equal(Object.prototype.hasOwnProperty.call(merged, "__proto__"), true); + assert.equal(({} as Record)["injected"], undefined); + }); +}); diff --git a/test/unit/codex-review-controls.test.ts b/test/unit/codex-review-controls.test.ts new file mode 100644 index 0000000..49dcbf5 --- /dev/null +++ b/test/unit/codex-review-controls.test.ts @@ -0,0 +1,102 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { PassThrough } from "node:stream"; +import { once } from "node:events"; +import { CodexNativeAuth } from "../../src/codex-native-auth.js"; +import { claudeFailure, ReverseContractError } from "../../src/claude-errors.js"; +import { ClaudeCache } from "../../src/claude-cache.js"; +import { WebSocketBudget } from "../../src/websocket-budget.js"; +import { namespaceRequest } from "../../src/collaboration-compat.js"; +import { replayIdentity } from "../../src/claude-state.js"; +import { openaiErrorBody, openaiFailureEvent, openaiWebSocketError } from "../../src/errors.js"; +import { claudeResolver, augmentCodexModels } from "../../src/claude-models.js"; +import { decideCodexRoute } from "../../src/codex-route.js"; +import { ok } from "../../src/result.js"; + +describe("reverse ingress review controls", () => { + it("keeps client, state, relay, and upstream failures in their explicit status buckets", () => { + for (const [code, status] of [ + ["invalid_state_key", 500], ["claude_retry_bound", 502], ["claude_response_too_large", 502], + ["duplicate_claude_start", 502], ["unsupported_claude_output", 502], ["invalid_claude_tool_arguments", 502], + ["request_too_large", 413], ["unsupported_content_encoding", 415], ["invalid_input_item", 400], + ["missing_continuation_state", 409], + ] as const) assert.equal(claudeFailure(new ReverseContractError(code)).status, status, code); + }); + + it("redacts error messages at every OpenAI render boundary", () => { + const secret = "Bearer this-is-a-private-token"; + const failure = { status: 502, code: "fixture", message: secret, retryAfter: secret }; + for (const wire of [openaiErrorBody(secret), JSON.stringify(openaiFailureEvent(secret, "fixture", { id: "r", model: "m", sequence: 1 })), + JSON.stringify(openaiWebSocketError(failure))]) { + assert.ok(!wire.includes("this-is-a-private-token")); assert.match(wire, /redacted/); + } + }); + + // MUTATION CHECK: removing exact endpoint membership makes the first auth read fail. + it("never reads operator credentials for arbitrary, escaped, or encoded native paths", async () => { + let reads = 0, refreshes = 0; + const credentials = ok({ provider: "codex" as const, authHeaders: { authorization: "Bearer operator-token", "chatgpt-account-id": "account" } }); + const auth = new CodexNativeAuth({ refreshable: true, getCredentials: async () => { reads++; return credentials; }, forceRefresh: async () => { refreshes++; return credentials; } }); + const req = new IncomingMessage(new Socket()); + req.headers = { "chatgpt-account-id": "account" }; req.rawHeaders = ["chatgpt-account-id", "account"]; + for (const path of ["/../../../backend-api/accounts/check", "/responses/../accounts", "/%72esponses", "/responses/", "/models/extra"]) { + assert.equal(await auth.headers(req, "subscription", path), req.rawHeaders); + assert.equal(await auth.headers(req, "subscription", path, true), req.rawHeaders); + } + assert.equal(reads + refreshes, 0); + assert.deepEqual((await auth.headers(req, "subscription", "/responses?native=1")).slice(-2), ["authorization", "Bearer operator-token"]); + assert.equal(reads, 1); + req.headers["chatgpt-account-id"] = "other-account"; + await assert.rejects(auth.headers(req, "subscription", "/models"), /account does not match/); + await assert.rejects(auth.headers(req, "subscription", "/models", true), /account does not match/); + req.headers.authorization = "Bearer client-token"; + assert.equal(await auth.headers(req, "subscription", "/responses"), req.rawHeaders); + }); + + it("shares one strict cache budget across Unicode snapshots, replay, and adaptation", () => { + const cache = new ClaudeCache({ maxEntries: 2, maxBytes: 160 }); + cache.put("adapted", "one", true); + cache.put("snapshot", "two", { request: {}, input: [] }); + cache.put("replay", "three", { content: [], output: [] }); + assert.equal(cache.get("adapted", "one"), undefined); + assert.equal(cache.size, 2); + cache.put("snapshot", "oversized", { request: { text: "😀".repeat(100) }, input: [] }); + assert.equal(cache.get("snapshot", "oversized"), undefined); + assert.ok(cache.byteSize <= 160); + }); + + it("holds capacity until upgraded sockets close and removes queued disconnects", async () => { + const budget = new WebSocketBudget(1); + const a = new PassThrough(), b = new PassThrough(), c = new PassThrough(); + const started: string[] = []; + budget.run(a, () => started.push("a")); + budget.run(b, () => started.push("b")); + budget.run(c, () => started.push("c")); + assert.deepEqual(started, ["a"]); + const bClosed = once(b, "close"); b.destroy(); await bClosed; + const aClosed = once(a, "close"); a.destroy(); await aClosed; + assert.deepEqual(started, ["a", "c"]); + budget.close(); assert.equal(c.destroyed, true); + }); + + it("preserves unchanged request identity and rejects deeply nested structured choices", () => { + const request = { model: "gpt-fixture", tools: [], input: [{ type: "message", content: "unchanged" }] }; + assert.equal(namespaceRequest(request), request); + let choice: unknown = { type: "function", name: "read" }; + for (let i = 0; i < 140; i++) choice = { type: "allowed_tools", tools: [choice] }; + assert.throws(() => namespaceRequest({ tool_choice: choice }), /json_nesting_too_deep/); + assert.throws(() => replayIdentity({ type: "function_call", arguments: "[".repeat(140) + "0" + "]".repeat(140) }), /json_nesting_too_deep/); + }); + + it("reports invalid aliases without throwing and keeps malformed catalog rows opaque", () => { + const resolve = claudeResolver({ worker: "claude-future", "gpt-claimed": "claude-future", invalid: "gpt-foreign" }); + assert.equal(resolve("worker"), "claude-future"); assert.equal(resolve("gpt-claimed"), undefined); + assert.deepEqual(resolve.rejectedAliases, ["gpt-claimed", "invalid"]); + const body = { models: [null, 42] }; assert.equal(augmentCodexModels(body, {}), body); + assert.deepEqual(decideCodexRoute("/responses", { kind: "claude", model: "claude-future" }), { kind: "claude", model: "claude-future" }); + assert.deepEqual(decideCodexRoute("/responses/compact", { kind: "claude", model: "claude-future" }), { kind: "rejected", code: "translated_compaction_unavailable" }); + assert.deepEqual(decideCodexRoute("/responses", { kind: "foreign" }), { kind: "parent" }); + }); +}); diff --git a/test/unit/compatibility-gates.test.ts b/test/unit/compatibility-gates.test.ts new file mode 100644 index 0000000..8b6e205 --- /dev/null +++ b/test/unit/compatibility-gates.test.ts @@ -0,0 +1,395 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { collaborationTools, TASK, PLAINTEXT_ARGUMENTS } from "../../e2e/gates/contracts.js"; +import { CredentialUnavailable, probeHeaders, type CredentialDeps } from "../../e2e/gates/credentials.js"; +import { runProbe, reportExitCode, type ProbeOptions } from "../../e2e/gates/probe.js"; +import { parseArgs } from "../../e2e/gates/run.js"; + +const completed = (output: unknown[] = []) => new Response(`data: ${JSON.stringify({ + type: "response.completed", response: { id: "resp_gate", status: "completed", output }, +})}\n\n`, { headers: { "content-type": "text/event-stream" } }); +const toolCall = () => completed([{ + type: "function_call", name: "spawn_agent", namespace: "collaboration", + arguments: JSON.stringify({ task_name: "probe", message: TASK }), +}]); + +function fakeFetch(responses: Response[]) { + const requests: { url: string; init: RequestInit; body: Record }[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + assert.ok(init); + requests.push({ url: String(input), init, body: JSON.parse(String(init.body)) }); + const response = responses.shift(); + assert.ok(response, "Unexpected retry or continuation after a failed gate"); + return response; + }; + return { fetchImpl, requests }; +} +const base: ProbeOptions = { provider: "openai", auth: "subscription", model: "fixture-model", headers: {} }; + +describe("bidirectional compatibility gates", () => { + it("changes exactly the three native message encryption annotations", () => { + const before = collaborationTools(false); + const after = collaborationTools(); + let changes = 0; + for (const tool of after.tools) { + const message = tool.parameters.properties["message"]; + if (["spawn_agent", "send_message", "followup_task"].includes(tool.name)) { + assert.equal(message?.encrypted, false); + message!.encrypted = true; + changes++; + } + } + assert.equal(changes, 3); + assert.deepEqual(after, before); + assert.deepEqual(collaborationTools(false), before, "fixture must not be mutated between requests"); + }); + + it("runs a native control before diagnosing modified schemas and stops on rejection", async () => { + const fake = fakeFetch([completed(), Response.json({ error: { + type: "invalid_request_error", param: "tools", + message: "Invalid Value: 'tools'. Function 'collaboration.followup_task' is reserved for use by this model and must match the configured schema.", + } }, { status: 400 })]); + const report = await runProbe({ ...base, fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 1); + assert.deepEqual(report.results.map((r) => [r.status, r.code]), [ + ["pass", "accepted"], ["fail", "reserved_collaboration_schema_followup_task"], + ]); + assert.equal(fake.requests.length, 2); + assert.equal(fake.requests[0]?.url, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(fake.requests[0]?.init.redirect, "error"); + const control = fake.requests[0]!.body; + const modified = structuredClone(fake.requests[1]!.body); + const input = modified["input"] as Record[]; + // The namespace-only test above proves exactly which booleans changed. This + // assertion also prevents changing the input/model/other request fields. + input[0]!["tools"] = [collaborationTools(false)]; + assert.deepEqual(modified, control); + }); + + it("does not label ciphertext or malformed JSON as plaintext", async () => { + for (const args of ["ciphertext", '{"message":', JSON.stringify({ message: "encrypted-value", task_name: "probe" })]) { + const fake = fakeFetch([completed(), completed(), completed([{ + type: "function_call", namespace: "collaboration", name: "spawn_agent", arguments: args, + }])]); + const report = await runProbe({ ...base, fetchImpl: fake.fetchImpl }); + assert.equal(report.results.at(-1)?.code, "plaintext_arguments_not_observed"); + assert.equal(fake.requests.length, 3); + } + }); + + it("tests native markers only after observing the expected readable arguments", async () => { + const fake = fakeFetch([completed(), completed(), toolCall(), completed()]); + const report = await runProbe({ ...base, auth: "api", fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 0); + assert.equal(fake.requests.length, 4); + assert.ok(fake.requests.every((r) => r.url === "https://api.openai.com/v1/responses")); + const input = fake.requests[3]?.body["input"] as Record[]; + assert.deepEqual(input.find((i) => i["type"] === "function_call")?.["encrypted_function_args"], PLAINTEXT_ARGUMENTS); + assert.deepEqual(input.find((i) => i["type"] === "agent_message")?.["content"], [{ type: "input_text", text: "gate-ok" }]); + }); + + it("tests plaintext history independently without modifying the reserved native schemas", async () => { + const fake = fakeFetch([completed(), completed()]); + const report = await runProbe({ ...base, contract: "native-history", fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 0); + assert.equal(report.results.at(-1)?.gate, "openai_native_plaintext_history"); + const input = fake.requests[1]?.body["input"] as Record[]; + assert.deepEqual(input[0]?.["tools"], [collaborationTools(false)]); + assert.deepEqual(input.find((i) => i["type"] === "function_call")?.["encrypted_function_args"], []); + const message = input.find((i) => i["type"] === "agent_message"); + assert.ok(String(message?.["id"]).startsWith("amsg")); + assert.deepEqual(message?.["content"], [{ type: "input_text", text: "gate-ok" }]); + assert.throws(() => parseArgs(["--provider", "claude", "--model", "fixture", "--contract", "native-history"])); + }); + + it("isolates each reserved encryption annotation and stops a matrix on availability failures", async () => { + const names = ["spawn_agent", "send_message", "followup_task"]; + const rejected = names.flatMap((name) => ["false", "omit"].map(() => Response.json({ error: { + type: "invalid_request_error", param: "tools", + message: `Invalid Value: 'tools'. Function 'collaboration.${name}' is reserved for use by this model and must match the configured schema.`, + } }, { status: 400 }))); + const fake = fakeFetch([completed(), ...rejected]); + const report = await runProbe({ ...base, contract: "schema-fields", fetchImpl: fake.fetchImpl }); + assert.equal(fake.requests.length, 7); + assert.equal(reportExitCode(report), 1); + for (const [index, name] of names.entries()) for (const [variant, mode] of ["false", "omit"].entries()) { + const requestIndex = 1 + index * 2 + variant; + const body = structuredClone(fake.requests[requestIndex]!.body); + const input = body["input"] as { tools: ReturnType[] }[]; + const message = input[0]!.tools[0]!.tools.find((tool) => tool.name === name)!.parameters.properties["message"]!; + assert.equal(message.encrypted, mode === "false" ? false : undefined); + message.encrypted = true; + assert.deepEqual(body, fake.requests[0]!.body, "only one annotation may differ from the control"); + assert.equal(report.results[requestIndex]?.code, `reserved_collaboration_schema_${name}`); + } + const unavailable = fakeFetch([completed(), Response.json({ error: { type: "rate_limit_error" } }, { status: 429 })]); + const stopped = await runProbe({ ...base, contract: "schema-fields", fetchImpl: unavailable.fetchImpl }); + assert.equal(unavailable.requests.length, 2); + assert.equal(reportExitCode(stopped), 2); + }); + + it("distinguishes ordinary namespace acceptance from native reserved-schema acceptance", async () => { + for (const message of [TASK, "fabricated-opaque-task"]) { + const fake = fakeFetch([completed(), completed(), completed([{ + type: "function_call", namespace: "subswitch_collaboration", name: "spawn_agent", + arguments: JSON.stringify({ task_name: "probe", model: "claude-sonnet-5", message }), + }]), ...(message === TASK ? [completed()] : [])]); + const report = await runProbe({ ...base, contract: "namespace-control", fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), message === TASK ? 0 : 1); + assert.equal(fake.requests.length, message === TASK ? 4 : 3); + const renamed = structuredClone(fake.requests[1]!.body); + const input = renamed["input"] as { tools: ReturnType[] }[]; + const namespace = input[0]!.tools[0]!; + assert.equal(namespace.name, "subswitch_collaboration"); + namespace.name = "collaboration"; + for (const tool of namespace.tools) if (["spawn_agent", "send_message", "followup_task"].includes(tool.name)) { + assert.equal(tool.parameters.properties["message"]?.encrypted, false); + tool.parameters.properties["message"]!.encrypted = true; + } + assert.deepEqual(renamed, fake.requests[0]!.body); + assert.deepEqual(collaborationTools(false).name, "collaboration"); + assert.ok(!JSON.stringify(report).includes("fabricated-opaque-task")); + } + }); + + it("checks generated Sonnet task readability without schema rewriting or executing an agent", async () => { + for (const message of [TASK, "fabricated-opaque-task"]) { + const fake = fakeFetch([completed(), completed([{ + type: "function_call", name: "spawn_agent", namespace: "collaboration", + arguments: JSON.stringify({ model: "claude-sonnet-5", task_name: "probe", message }), + }])]); + const report = await runProbe({ ...base, contract: "native-arguments", fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), message === TASK ? 0 : 1); + assert.equal(fake.requests.length, 2); + const input = fake.requests[1]?.body["input"] as Record[]; + assert.deepEqual(input[0]?.["tools"], [collaborationTools(false)]); + assert.ok(!JSON.stringify(report).includes("fabricated-opaque-task")); + } + }); + + it("uses completed output items when Responses-lite omits terminal output", async () => { + const tool = { type: "function_call", name: "spawn_agent", namespace: "collaboration", + arguments: JSON.stringify({ task_name: "probe", message: TASK }), encrypted_function_args: [] }; + const stream = new Response(`data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: tool })}\n\n` + + `data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", output: [] } })}\n\n`); + const fake = fakeFetch([completed(), completed(), stream, completed()]); + const report = await runProbe({ ...base, fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 0); + assert.equal(fake.requests.length, 4); + }); + + it("rejects missing or duplicated output indices even when a terminal event arrives", async () => { + for (const indices of [[1], [0, 0]]) { + const stream = new Response(indices.map((output_index) => `data: ${JSON.stringify({ + type: "response.output_item.done", output_index, item: { type: "message", content: [] }, + })}\n\n`).join("") + `data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", output: [] } })}\n\n`); + const fake = fakeFetch([stream]); + const report = await runProbe({ ...base, fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 1); + assert.equal(fake.requests.length, 1); + } + }); + + it("keeps upstream/auth failures blocked with retry guidance and makes no fallback requests", async () => { + for (const status of [401, 403, 429, 500, 503]) { + const fake = fakeFetch([Response.json({ error: { type: "rate_limit_error", message: "secret upstream text" } }, { + status, headers: { "retry-after": "120", "set-cookie": "secret=credential" }, + })]); + const report = await runProbe({ ...base, provider: "claude", fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 2); + assert.deepEqual(report.results, [{ + gate: "claude_tool_call", status: "blocked", code: "rate_limit_error", httpStatus: status, retryAfter: "120", + }]); + assert.equal(fake.requests.length, 1); + assert.ok(!JSON.stringify(report).includes("secret")); + } + }); + + it("distinguishes an explicit Claude spend limit from an unexplained 429 without leaking details", async () => { + for (const [errorCode, expected] of [ + [undefined, "rate_limit_error"], + ["private-unknown-code", "rate_limit_error"], + ["enforced_spend_limit_reached", "enforced_spend_limit_reached"], + ]) { + const fake = fakeFetch([Response.json({ error: { + type: "rate_limit_error", message: "Error", + details: { error_code: errorCode, private_context: "secret" }, + } }, { status: 429 })]); + const report = await runProbe({ ...base, provider: "claude", fetchImpl: fake.fetchImpl }); + assert.deepEqual(report.results, [{ + gate: "claude_tool_call", status: "blocked", code: expected, httpStatus: 429, + }]); + assert.equal(fake.requests.length, 1); + assert.ok(!JSON.stringify(report).includes("secret")); + assert.ok(!JSON.stringify(report).includes("private-unknown-code")); + } + }); + + it("redacts unknown error codes, raw errors, headers, and invalid Retry-After", async () => { + const fake = fakeFetch([Response.json({ error: { type: "secret", message: "secret" } }, { + status: 400, headers: { "retry-after": "secret" }, + })]); + const report = await runProbe({ ...base, fetchImpl: fake.fetchImpl }); + assert.equal(report.results[0]?.code, "upstream_http_error"); + assert.ok(!JSON.stringify(report).includes("secret")); + }); + + it("requires a completed stream with a valid terminal status", async () => { + for (const frame of [ + 'data: [DONE]\n\n', + 'data: {"type":"response.output_item.done","item":{"type":"message"}}\n\n', + 'data: {"type":"response.failed"}\n\n', + 'data: {"type":"response.completed","response":{"status":"incomplete","output":[]}}\n\n', + 'data: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\ndata: {"type":"response.failed"}\n\n', + 'data: {broken}\n\n', + ]) { + const fake = fakeFetch([new Response(frame)]); + assert.equal(reportExitCode(await runProbe({ ...base, fetchImpl: fake.fetchImpl })), 1); + assert.equal(fake.requests.length, 1); + } + }); + + it("preserves Claude assistant state verbatim across a real-shaped tool continuation", async () => { + const content = [ + { type: "thinking", thinking: "fabricated private reasoning", signature: "fabricated-signature" }, + { type: "redacted_thinking", data: "fabricated-opaque-state" }, + { type: "tool_use", id: "toolu_gate", name: "echo", input: { text: "gate-ok" } }, + ]; + const fake = fakeFetch([ + Response.json({ type: "message", stop_reason: "tool_use", content }), + Response.json({ type: "message", stop_reason: "end_turn", content: [{ type: "text", text: "gate-ok" }] }), + ]); + const report = await runProbe({ ...base, provider: "claude", fetchImpl: fake.fetchImpl }); + assert.equal(reportExitCode(report), 0); + const messages = fake.requests[1]?.body["messages"] as Record[]; + assert.deepEqual(messages[1]?.["content"], content); + assert.deepEqual(messages[2]?.["content"], [{ type: "tool_result", tool_use_id: "toolu_gate", content: "gate-ok" }]); + assert.ok(!JSON.stringify(report).includes("fabricated")); + }); + + it("rejects truncated Claude tool output and incomplete continuations", async () => { + const fake = fakeFetch([Response.json({ stop_reason: "max_tokens", content: [ + { type: "tool_use", id: "toolu_gate", name: "echo", input: { text: "gate-ok" } }, + ] })]); + const report = await runProbe({ ...base, provider: "claude", fetchImpl: fake.fetchImpl }); + assert.equal(report.results[0]?.code, "unexpected_tool_call"); + assert.equal(fake.requests.length, 1); + const continuation = fakeFetch([ + Response.json({ stop_reason: "tool_use", content: [ + { type: "tool_use", id: "toolu_gate", name: "echo", input: { text: "gate-ok" } }, + ] }), + Response.json({ stop_reason: "max_tokens", content: [{ type: "text", text: "gate-ok" }] }), + ]); + const second = await runProbe({ ...base, provider: "claude", fetchImpl: continuation.fetchImpl }); + assert.equal(second.results[1]?.code, "invalid_continuation"); + }); + + it("cancels an oversized response and never reports it as successful", async () => { + let cancelled = false; + const fake = fakeFetch([new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(1024 * 1024 + 1)); }, + cancel() { cancelled = true; }, + }))]); + const report = await runProbe({ ...base, fetchImpl: fake.fetchImpl }); + assert.equal(report.results[0]?.code, "response_too_large"); + assert.equal(cancelled, true); + }); + + it("bounds hanging response bodies and redacts network exception messages", async () => { + const fake = fakeFetch([new Response(new ReadableStream())]); + const report = await runProbe({ ...base, timeoutMs: 10, fetchImpl: fake.fetchImpl }); + assert.equal(report.results[0]?.code, "request_timeout"); + const network = await runProbe({ ...base, fetchImpl: async () => { throw new Error("secret credential"); } }); + assert.equal(network.results[0]?.code, "network_error"); + assert.equal(reportExitCode(network), 2); + assert.ok(!JSON.stringify(network).includes("secret")); + }); +}); + +describe("gate credential isolation and CLI", () => { + function deps(overrides: Partial = {}): CredentialDeps { + return { + env: { OPENAI_API_KEY: "openai-api-secret", ANTHROPIC_API_KEY: "claude-api-secret" }, + home: "/test-home", platform: "linux", now: () => 1000, + read: async () => { throw new Error("Must not read a store for API authentication"); }, + keychain: async () => { throw new Error("Must not read Keychain for API authentication"); }, + ...overrides, + }; + } + + it("uses only the selected provider's explicitly chosen API environment variable", async () => { + const openai = await probeHeaders({ provider: "openai", auth: "api" }, deps()); + assert.equal(openai["authorization"], "Bearer openai-api-secret"); + assert.equal(openai["chatgpt-account-id"], undefined); + assert.equal(openai["openai-beta"], undefined); + assert.ok(!JSON.stringify(openai).includes("claude-api-secret")); + const claude = await probeHeaders({ provider: "claude", auth: "api" }, deps()); + assert.equal(claude["x-api-key"], "claude-api-secret"); + assert.equal(claude["authorization"], undefined); + assert.equal(claude["anthropic-beta"], undefined); + assert.ok(!JSON.stringify(claude).includes("openai-api-secret")); + const named = await probeHeaders({ provider: "claude", auth: "api", envName: "MY_KEY" }, deps({ env: { MY_KEY: "named-secret" } })); + assert.equal(named["x-api-key"], "named-secret"); + }); + + it("does not fall back from a missing API environment variable to subscription", async () => { + await assert.rejects(probeHeaders({ provider: "openai", auth: "api" }, deps({ env: {} })), + (e: unknown) => e instanceof CredentialUnavailable && e.code === "api_key_env_missing"); + }); + + it("respects CODEX_HOME and keeps the subscription account header provider-specific", async () => { + const headers = await probeHeaders({ provider: "openai", auth: "subscription" }, deps({ + env: { CODEX_HOME: "/isolated/codex" }, + read: async (path) => { + assert.equal(path, "/isolated/codex/auth.json"); + return JSON.stringify({ tokens: { access_token: "codex-oauth", account_id: "account-fixture" } }); + }, + })); + assert.equal(headers["authorization"], "Bearer codex-oauth"); + assert.equal(headers["chatgpt-account-id"], "account-fixture"); + assert.equal(headers["x-api-key"], undefined); + }); + + it("reads the selected Claude file store without copying or refreshing credentials", async () => { + const headers = await probeHeaders({ provider: "claude", auth: "subscription" }, deps({ + env: { CLAUDE_CONFIG_DIR: "/isolated/claude", ANTHROPIC_API_KEY: "must-not-use" }, + read: async (path) => { + assert.equal(path, "/isolated/claude/.credentials.json"); + return JSON.stringify({ claudeAiOauth: { accessToken: "claude-oauth", expiresAt: 2000 } }); + }, + })); + assert.equal(headers["authorization"], "Bearer claude-oauth"); + assert.equal(headers["x-api-key"], undefined); + assert.equal(headers["chatgpt-account-id"], undefined); + }); + + it("surfaces locked Keychain errors without trying other credentials", async () => { + await assert.rejects(probeHeaders({ provider: "claude", auth: "subscription" }, deps({ + platform: "darwin", keychain: async (service) => { + assert.equal(service, "Claude Code-credentials"); + throw new Error("Keychain secret internals"); + }, + })), (e: unknown) => e instanceof CredentialUnavailable && e.code === "claude_keychain_unavailable"); + }); + + it("requires the native client to refresh expired tokens; never uses an available API key", async () => { + const token = `eyJ.${Buffer.from(JSON.stringify({ exp: 0 })).toString("base64url")}.sig`; + await assert.rejects(probeHeaders({ provider: "openai", auth: "subscription" }, deps({ + read: async () => JSON.stringify({ tokens: { access_token: token, account_id: "fixture" } }), + })), (e: unknown) => e instanceof CredentialUnavailable && e.code === "codex_subscription_expired"); + await assert.rejects(probeHeaders({ provider: "claude", auth: "subscription" }, deps({ + read: async () => JSON.stringify({ claudeAiOauth: { accessToken: "token", expiresAt: 999 } }), + })), (e: unknown) => e instanceof CredentialUnavailable && e.code === "claude_subscription_expired"); + }); + + it("defaults to subscription and rejects literal keys, ambiguous flags, and absent models", () => { + assert.deepEqual(parseArgs(["--provider", "claude", "--model", "claude-sonnet-5"]), { + provider: "claude", auth: "subscription", model: "claude-sonnet-5", + }); + for (const args of [[], ["--provider", "claude"], + ["--provider", "claude", "--model", "fixture", "--key-env", "ANTHROPIC_API_KEY"], + ["--provider", "claude", "--model", "fixture", "--auth", "api", "--key-env", "sk-literal-key"], + ["--provider", "claude", "--model", "fixture", "--auth", "api", "--auth", "subscription"], + ]) assert.throws(() => parseArgs(args)); + }); +}); diff --git a/test/unit/namespace-adapter.test.ts b/test/unit/namespace-adapter.test.ts new file mode 100644 index 0000000..30ea17c --- /dev/null +++ b/test/unit/namespace-adapter.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { BRIDGE_NAMESPACE, NamespaceContractError, namespaceRequest, namespaceEvent } from "../../src/collaboration-compat.js"; +import { collaborationTools } from "../../e2e/gates/contracts.js"; + +describe("experimental collaboration namespace protocol adapter", () => { + it("maps schemas, replayed calls and tool choices without editing prompt or opaque content", () => { + const message = { type: "message", role: "user", content: "Literal collaboration.spawn_agent must stay intact." }; + const reasoning = { type: "reasoning", encrypted_content: "fabricated-opaque-state" }; + const call = { type: "function_call", namespace: "collaboration", name: "spawn_agent", call_id: "call_fixture", arguments: '{"message":"original"}' }; + const namespace = collaborationTools(false); + const request = { input: [{ type: "additional_tools", tools: [namespace] }, message, reasoning, call], + tool_choice: { type: "allowed_tools", mode: "required", tools: [{ type: "function", namespace: "collaboration", name: "spawn_agent" }] } }; + const before = structuredClone(request); + const mapped = namespaceRequest(request) as typeof request; + assert.deepEqual(request, before); + assert.equal(mapped.input[1], message); + assert.equal(mapped.input[2], reasoning); + assert.deepEqual(mapped.input[3], { ...call, namespace: BRIDGE_NAMESPACE }); + assert.equal(mapped.tool_choice.tools[0]?.namespace, BRIDGE_NAMESPACE); + const changed = (mapped.input[0] as { tools: ReturnType[] }).tools[0]!; + assert.equal(changed.name, BRIDGE_NAMESPACE); + for (const tool of changed.tools) if (["spawn_agent", "send_message", "followup_task"].includes(tool.name)) + assert.equal(tool.parameters.properties["message"]?.encrypted, false); + }); + + it("keeps native identity and plaintext metadata consistent across streaming events", () => { + const item = { type: "function_call", namespace: BRIDGE_NAMESPACE, name: "spawn_agent", id: "fc_fixture", call_id: "call_fixture", arguments: "" }; + const added = namespaceEvent({ type: "response.output_item.added", output_index: 2, item }); + assert.deepEqual(added["item"], { ...item, namespace: "collaboration", encrypted_function_args: [] }); + const complete = { ...item, arguments: '{"message":"known plaintext"}' }; + const expected = { ...complete, namespace: "collaboration", encrypted_function_args: [] }; + assert.deepEqual(namespaceEvent({ type: "response.output_item.done", output_index: 2, item: complete })["item"], expected); + const final = namespaceEvent({ type: "response.completed", response: { id: "resp_fixture", output: [complete] } }); + assert.deepEqual(final["response"], { id: "resp_fixture", output: [expected] }); + assert.equal(complete.namespace, BRIDGE_NAMESPACE); + }); + + it("rejects incomplete arguments and explicit encryption instead of making an executable call", () => { + for (const item of [ + { arguments: '{"message":' }, + { arguments: '{"message":17}' }, + { arguments: '{"message":"fabricated opaque"}', encrypted_function_args: ["message"] }, + { arguments: '{"message":"text"}', encrypted_function_args: "[plaintext arguments]" }, + ]) assert.throws(() => namespaceEvent({ type: "response.output_item.done", item: { + type: "function_call", namespace: BRIDGE_NAMESPACE, name: "send_message", ...item, + } }), (error: unknown) => error instanceof NamespaceContractError && error.code === "invalid_plaintext_call"); + }); + + it("leaves original native encryption and unrelated output untouched", () => { + const encryptedCall = { type: "function_call", namespace: "collaboration", name: "spawn_agent", arguments: "fabricated opaque" }; + const event = { type: "response.output_item.done", item: encryptedCall }; + assert.equal(namespaceEvent(event)["item"], encryptedCall); + const reasoning = { type: "reasoning", encrypted_content: "fabricated opaque" }; + assert.equal(namespaceEvent({ type: "response.output_item.done", item: reasoning })["item"], reasoning); + const agentMessage = { type: "agent_message", content: [{ type: "encrypted_content", encrypted_content: "fabricated opaque" }] }; + assert.deepEqual(namespaceRequest({ input: [agentMessage] }), { input: [agentMessage] }); + }); + + it("fails explicitly on an existing namespace collision", () => { + for (const request of [ + { tools: [{ type: "namespace", name: BRIDGE_NAMESPACE, tools: [] }] }, + { input: [{ type: "additional_tools", tools: [{ type: "namespace", name: BRIDGE_NAMESPACE, tools: [] }] }] }, + ]) assert.throws(() => namespaceRequest(request), + (error: unknown) => error instanceof NamespaceContractError && error.code === "namespace_collision"); + }); +}); diff --git a/test/unit/native-process.test.ts b/test/unit/native-process.test.ts new file mode 100644 index 0000000..0e8e212 --- /dev/null +++ b/test/unit/native-process.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { tmpdir } from "node:os"; +import { nativeProcess, isolatedNativeEnv } from "../../e2e/gates/native-process.js"; +describe("isolated native process lifecycle", () => { + const options = { cwd: tmpdir(), env: isolatedNativeEnv({}), timeoutMs: 3000 }; + + it("closes stdin so a native CLI can start processing its argument prompt", async () => { + const result = await nativeProcess(process.execPath, ["-e", "process.stdin.resume(); process.stdin.on('end',()=>console.log('eof-observed'));"], options); + assert.equal(result.code, 0); + assert.equal(result.failure, undefined); + assert.equal(result.stdout.trim(), "eof-observed"); + }); + + it("does not mistake a graceful exit after the deadline for success", async () => { + const result = await nativeProcess(process.execPath, ["-e", "process.on('SIGTERM',()=>process.exit(0)); setInterval(()=>{},1000);"], { + ...options, timeoutMs: 200, + }); + assert.equal(result.failure, "timeout"); + }); + + it("terminates an output flood without keeping the oversized chunk", async () => { + const result = await nativeProcess(process.execPath, ["-e", "process.stdout.write('x'.repeat(100000)); setInterval(()=>{},1000);"], { + ...options, maxBytes: 100, + }); + assert.equal(result.failure, "output_limit"); + assert.ok(result.stdout.length <= 100); + }); + + it("reports a missing client without exposing spawn exception details", async () => { + const result = await nativeProcess("/nonexistent-subswitch-native-client", [], options); + assert.equal(result.failure, "spawn_error"); + assert.equal(result.stderr, ""); + }); + + it("starts with explicit test credentials and no inherited client configuration", () => { + const env = isolatedNativeEnv({ CODEX_HOME: "/fabricated-home", OPENAI_API_KEY: "fabricated-key" }); + assert.equal(env["CODEX_HOME"], "/fabricated-home"); + assert.equal(env["OPENAI_API_KEY"], "fabricated-key"); + for (const key of ["CODEX_THREAD_ID", "CODEX_SESSION_ID", "SUBSWITCH_CONFIG", "ANTHROPIC_API_KEY", "CLAUDE_CONFIG_DIR", "NODE_OPTIONS"]) + assert.equal(env[key], undefined); + }); +}); diff --git a/test/unit/reverse-adapter.test.ts b/test/unit/reverse-adapter.test.ts new file mode 100644 index 0000000..6ffeba2 --- /dev/null +++ b/test/unit/reverse-adapter.test.ts @@ -0,0 +1,130 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { reverseRequest, reverseResponse, reverseEvents, ReverseContractError } from "../../src/claude-adapter.js"; +import { ReverseState, replayIdentity } from "../../src/claude-state.js"; + +const code = { type: "custom", name: "exec", description: "Execute JavaScript with the native tools object.", format: { type: "text" } }; +const tool = { type: "function", name: "read", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } }; +const request = () => ({ model: "claude-sonnet-5", instructions: "Keep all instructions.", + input: [{ type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [code, tool] }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "Read the fixture." }] }], +}); +const rejects = (fn: () => unknown, code: string) => assert.throws(fn, error => error instanceof ReverseContractError && error.code === code); + +describe("experimental reverse native contract", () => { + it("restores freeform namespace/type/input and replays its result without rewriting text", () => { + const original = request(); const snapshot = structuredClone(original); + const translated = reverseRequest(original); + assert.deepEqual(original, snapshot); + const mapping = [...translated.tools.values()].find(tool => tool.type === "custom")!; + const input = 'const r = await tools.exec_command({cmd:"cat check.txt"}); text(r);'; + const output = reverseResponse({ type: "message", stop_reason: "tool_use", content: [ + { type: "tool_use", id: "toolu_test", name: mapping.wire, input: { input } }, + ] }, translated); + assert.equal(output[0]?.["type"], "custom_tool_call"); + assert.equal(output[0]?.["namespace"], "functions"); + assert.equal(output[0]?.["name"], "exec"); + assert.equal(output[0]?.["input"], input); + const continued = reverseRequest({ ...original, input: [...original.input, ...output, + { type: "custom_tool_call_output", call_id: "toolu_test", output: [{ type: "input_text", text: "unpredictable-result" }] }] }); + assert.deepEqual((continued.body["messages"] as Record[]).at(-1), { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_test", content: [{ type: "text", text: "unpredictable-result" }] }, + ] }); + }); + + it("maps identical local tool names in different namespaces to distinct stable names", () => { + const translated = reverseRequest({ ...request(), tools: [ + { type: "namespace", name: "other", tools: [code] }, + ] }); + const tools = [...translated.tools.values()].filter(tool => tool.name === "exec"); + assert.equal(tools.length, 2); assert.notEqual(tools[0]?.wire, tools[1]?.wire); + assert.equal(tools[1]?.wire, [...reverseRequest(request()).tools.values()][0]?.wire); + }); + + it("does not convert invalid or truncated output into executable native calls", () => { + const translated = reverseRequest(request()); + const mapping = [...translated.tools.values()][0]!; + rejects(() => reverseResponse({ type: "message", stop_reason: "max_tokens", content: [ + { type: "tool_use", id: "toolu_test", name: mapping.wire, input: { input: "unfinished(" } }, + ] }, translated), "incomplete_claude_response"); + rejects(() => reverseResponse({ type: "message", stop_reason: "tool_use", content: [ + { type: "tool_use", id: "toolu_test", name: "unknown", input: {} }, + ] }, translated), "unknown_claude_tool"); + rejects(() => reverseResponse({ type: "message", stop_reason: "tool_use", content: [ + { type: "tool_use", id: "toolu_test", name: mapping.wire, input: { input: "text", ignored: true } }, + ] }, translated), "invalid_custom_tool_input"); + }); + + it("rejects opaque state, encrypted calls, and orphaned results explicitly", () => { + const base = request(); + rejects(() => reverseRequest({ ...base, input: [...base.input, { type: "reasoning", encrypted_content: "opaque" }] }), "opaque_state_unimplemented"); + rejects(() => reverseRequest({ ...base, input: [...base.input, { type: "function_call_output", call_id: "missing", output: "result" }] }), "unmatched_tool_result"); + rejects(() => reverseRequest({ ...base, input: [...base.input, { type: "function_call", namespace: "functions", name: "read", call_id: "call", arguments: "{}", encrypted_function_args: ["path"] }] }), "encrypted_tool_arguments"); + }); + + it("preserves image-bearing tool results and rejects unrepresentable blocks", () => { + const base = request(); + const continued = reverseRequest({ ...base, input: [...base.input, + { type: "function_call", namespace: "functions", name: "read", call_id: "call", arguments: '{"path":"image.png"}' }, + { type: "function_call_output", call_id: "call", output: [{ type: "input_image", image_url: "data:image/png;base64,AAAA" }] }, + ] }); + assert.match(JSON.stringify(continued.body), /"media_type":"image\/png","data":"AAAA"/); + rejects(() => reverseRequest({ ...base, input: [{ type: "message", role: "user", content: [{ type: "input_audio", data: "opaque" }] }] }), "unsupported_content_block"); + }); + + it("emits matched custom-call events and terminal output with stable identities", () => { + const output = [{ type: "custom_tool_call", id: "fc_test", call_id: "call_test", namespace: "functions", name: "exec", input: "text(1)", status: "completed" }]; + const events = reverseEvents("resp_test", "claude-sonnet-5", output); + assert.equal(events.find(event => event["type"] === "response.custom_tool_call_input.delta")?.["item_id"], "fc_test"); + assert.deepEqual(events.find(event => event["type"] === "response.output_item.done")?.["item"], output[0]); + assert.equal(events.at(-1)?.["type"], "response.completed"); + assert.deepEqual(events.map(event => event["sequence_number"]), events.map((_, index) => index)); + assert.deepEqual((events.at(-1)?.["response"] as Record)["usage"], { + input_tokens: 0, output_tokens: 0, total_tokens: 0, input_tokens_details: { cached_tokens: 0 }, + }); + const cached = reverseEvents("resp_cached", "claude-sonnet-5", [], { input_tokens: 10, output_tokens: 5, + cache_creation_input_tokens: 20, cache_read_input_tokens: 30 }); + assert.deepEqual((cached.at(-1)?.["response"] as Record)["usage"], { + input_tokens: 60, output_tokens: 5, total_tokens: 65, input_tokens_details: { cached_tokens: 30 }, + }); + }); + + it("preserves signed thinking verbatim through encrypted state and a fresh codec instance", () => { + const key = Buffer.alloc(32, 9), writer = new ReverseState(key), reader = new ReverseState(key); + const base = request(), translated = reverseRequest(base); + const tool = [...translated.tools.values()][0]!; + const original = [ + { type: "thinking", thinking: "private fixture thinking", signature: "fixture-signed-state" }, + { type: "redacted_thinking", data: "fixture-redacted-state" }, + { type: "tool_use", id: "toolu_thinking", name: tool.wire, input: { input: "text(1)" } }, + ]; + const output = reverseResponse({ type: "message", stop_reason: "tool_use", content: original }, translated, writer); + assert.equal(output[0]?.["type"], "reasoning"); + assert.ok(!JSON.stringify(output).includes("fixture-signed-state")); + const continued = reverseRequest({ ...base, input: [...base.input, ...output, + { type: "custom_tool_call_output", call_id: "toolu_thinking", output: "1" }] }, [], reader); + const assistant = (continued.body["messages"] as Record[]).find(message => message["role"] === "assistant"); + assert.deepEqual(assistant?.["content"], original); + rejects(() => reverseRequest({ ...base, input: [...base.input, ...output, + { type: "custom_tool_call_output", call_id: "toolu_thinking", output: "1" }] }, [], new ReverseState()), "invalid_opaque_state"); + const altered = structuredClone(output); altered[1]!["input"] = "text(2)"; + rejects(() => reverseRequest({ ...base, input: [...base.input, ...altered] }, [], reader), "state_history_mismatch"); + }); + + it("preserves supported effort settings and rejects unsupported values", () => { + const active = reverseRequest({ ...request(), reasoning: { effort: "medium" } }); + assert.deepEqual(active.body["thinking"], { type: "adaptive" }); + assert.deepEqual(active.body["output_config"], { effort: "medium" }); + const disabled = reverseRequest({ ...request(), reasoning: { effort: "none" } }); + assert.deepEqual(disabled.body["thinking"], { type: "disabled" }); + rejects(() => reverseRequest({ ...request(), reasoning: { effort: "ultra" } }), "unsupported_reasoning_effort"); + }); + it("accepts native replay serialization while preserving meaningful content and call arguments", () => { + assert.equal(replayIdentity({ type: "message", role: "assistant", content: [{ type: "output_text", text: "hello", annotations: [] }] }), + replayIdentity({ type: "message", role: "assistant", content: [{ type: "input_text", text: "hello" }] })); + assert.notEqual(replayIdentity({ type: "message", role: "assistant", content: "hello" }), replayIdentity({ type: "message", role: "assistant", content: "changed" })); + const call = { type: "function_call", namespace: "functions", name: "read", call_id: "one" }; + assert.equal(replayIdentity({ ...call, arguments: '{"a":1,"b":2}' }), replayIdentity({ ...call, arguments: '{ "b": 2, "a": 1 }' })); + assert.notEqual(replayIdentity({ ...call, arguments: '{"a":1}' }), replayIdentity({ ...call, arguments: '{"a":2}' })); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index dfeb922..3aca253 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "e2e/gates/**/*.ts"] }