Skip to content
Merged
3 changes: 3 additions & 0 deletions devlog/_plan/260906_a_runtime_stack/021_ws_refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# WebSocket layer P refresh

Consume 020 above prepared SSE parent 4b34cbb8d, with source #3679 b05cccf264b4ab61db5d8dee8232c2f89bb1b541. Public author updated the old head and resolved the three original review threads. Retain Clive Rosfield attribution and -x source identity. Existing shared proxy-formats documentation contains SSE paragraph; preserve both sections. B owns concurrent providerContextLimits config changes; A updates only applyProxyEnv. This layer stays independently verified and draft while full CI runs; main merges only after full required gates. No local project checks.
3 changes: 3 additions & 0 deletions devlog/_plan/260906_a_runtime_stack/031_recovery_refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Recovery layer P refresh

Consume 030 above prepared WS parent10fbda2e0. Original #3568 remains open at036a9321788464fdf33a387c9f44a834a844bdc1; carry all three voiys commits in order. No catalog/effort hunks from #3571. Add the planned cache exact-expiry/no-TTL-extension and canonical-Go conversion negatives, with a scoped inherited worker owning only the named three regression files after original carry. Main owns production integration, author commits and review. Runtime correction: isolated checks now invoke repository node_modules/.bin/bun and assert package.json dependencies.bun=1.4.0 before any execution. Full per-head CI remains mandatory before landing. #3661 remains partial, with no automatic close reference.
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,12 @@ A provider is included when opencodex has a matching wire adapter, **not** based
(AI Studio, Vertex, and Antigravity/Cloud Code Assist modes), `azure` / `azure-openai`, `kiro`, and
`cursor`. A proprietary API without one of these implementations, such as native Amazon Bedrock,
is not supported directly.

Provider configuration selects the adapter; upstream transport selection is separate. Eligible
Responses traffic can use WSS with [explicit proxy routing](/reference/proxy-formats/#json-and-sse-output).
Invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE, which uses Bun's HTTP
proxy rules rather than the WSS-specific `ALL_PROXY` fallback.

**GitHub Copilot** is an OAuth provider (`ocx login github-copilot`) that exchanges a GitHub
device-flow login for a short-lived Copilot API token — not a pasted API key. **GitLab Duo** remains
a key/subscription-token gateway on its OpenAI-compatible endpoint. **Cloudflare AI
Expand Down
12 changes: 12 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,23 @@ body and response, with narrow compatibility rewrites for routed gateways.
`forward` uses configured static headers without relaying caller authorization; `key` uses the
configured provider key.

Adapter selection does not select the upstream transport. Eligible requests can use the
[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported
WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses Bun's
HTTP proxy rules and does not inherit the WSS-specific `ALL_PROXY` fallback.

Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a
collision-safe public function tool. Matching request history and JSON/SSE function calls are
translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward
keeps the native private type unchanged.

For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other
than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient
metadata. This conversion leaves encrypted or unknown content unchanged and does not apply
to other destinations. Providers using `authMode: "forward"` retain these items unchanged.
See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages)
for the separate opt-in encrypted-task recovery behavior.

The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that
its stricter backend rejects: fully textual `system` messages inside `input` are appended to the
top-level `instructions` string in request order, and the top-level `truncation` field is removed.
Expand Down
29 changes: 29 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -810,3 +810,32 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5
"visionSidecar": { "enabled": true }
}
```

## OpenCode Go session and agent messages

With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and
base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items
become user messages when `authMode` is not `"forward"` (for example, `"key"`).
Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including
renamed provider entries; other Responses destinations keep their input unchanged.
Author and recipient remain explicit text metadata, and the content parts are preserved.
Encrypted and unknown content is not normalized; native encrypted tasks still require the
separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery).

With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only
after validating the caller and matching the parent-thread scope. Replay restoration
does not make a new recovery request or extend cache expiry. Expired or unseen
ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same
opt-in recovery path, including native-parent `send_message` delivery. Message type,
sender, recipient, parent scope and caller credentials remain part of validation or cache identity.

When a request contains several agent messages, cached replay restoration checks each
message independently. The cache separates message type, sender, recipient and ciphertext
within the admitted caller/account and parent scope. Fresh recovery only handles the
current tail message (ignoring trailing `compaction_trigger` or `additional_tools` metadata).
It does not batch-recover unseen historical messages; those remain unchanged. A cache miss
or expiry does not extend the history-recovery contract.

Sender and recipient on Go Responses are context for the receiving model, not a new
machine-readable routing protocol. Tool routing continues to use the existing collaboration
contracts.
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ the raw JSON frame and its SSE envelope at 4 MiB, and closes the upstream when i
would overflow. That overflow emits a terminal downstream `response.failed` event followed by
`[DONE]`.

The upstream WebSocket checks `NO_PROXY`/`no_proxy` first. Otherwise it uses the first non-empty
`HTTPS_PROXY`, `https_proxy`, `ALL_PROXY`, or `all_proxy` value; `HTTP_PROXY` alone does not proxy a
WSS connection. HTTP and HTTPS proxy URLs are passed to Bun. If the selected value is invalid or
uses an unsupported protocol, opencodex skips the WebSocket attempt and uses HTTP/SSE instead of
dialing the upstream directly.

These rules belong to the upstream WebSocket transport, independently of the selected provider
adapter. HTTP fetch-based Responses requests, including SSE fallback, use Bun's HTTP proxy rules
and do not use `ALL_PROXY`. `config.proxy` fills missing `HTTP_PROXY`/`HTTPS_PROXY` values; the
resulting scheme-specific value also takes precedence over an existing `ALL_PROXY` for WebSocket.
For an HTTPS upstream that requires a proxy, set `HTTPS_PROXY` or `config.proxy`; `HTTP_PROXY`
alone leaves both WSS and its HTTPS fallback without a scheme-matched proxy.

Every terminal Responses usage object includes both detail objects, even when the provider did not
report those details:

Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@
"openai-responses-passthrough.test.ts": "responses",
"opencode-cli.test.ts": "providers",
"opencode-free-provider.test.ts": "providers",
"opencode-go-agent-messages.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers",
"opencode-go-grok46-responses.test.ts": "providers",
"opencode-go-luna-wire.test.ts": "providers",
Expand Down Expand Up @@ -1064,6 +1065,7 @@
"selected-models.test.ts": "codex-integration",
"self-launch-argv.test.ts": "lib",
"server-403-permission-e2e.test.ts": "server",
"server-agent-task-recovery-replay.test.ts": "server",
"server-auth.test.ts": "server",
"server-background-lifecycle.test.ts": "server",
"server-clickjacking-headers.test.ts": "server",
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go";
import { createHash } from "node:crypto";
import type { IncomingMeta, ProviderAdapter } from "./base";
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
Expand Down Expand Up @@ -2355,6 +2356,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
parsed._rawBody,
forward || parsed._previousResponseInputExpanded === true,
);
if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody);
outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
// stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
// tier write so a force-fast/default decision can never mutate parsed._rawBody.
Expand Down
35 changes: 35 additions & 0 deletions src/adapters/opencode-go.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** Match the Go destination, including user-renamed provider entries. */
export function isOpenCodeGo(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1";
} catch { return false; }
}

/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */
export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
if (!Array.isArray(record.input)) return body;
let changed = false;
const input = record.input.map((item: unknown) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const message = item as Record<string, unknown>;
if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item;
// Genuine ciphertext and unknown part types must retain their existing fail-closed path.
if (!message.content.every(part => part && typeof part === "object"
&& ["input_text", "input_image", "input_file"].includes(part.type))) return item;
const identities = Object.fromEntries(["author", "recipient"]
.filter(key => typeof message[key] === "string")
.map(key => [key, message[key]]));
changed = true;
return {
type: "message", role: "user",
content: [
...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []),
...message.content,
],
};
});
return changed ? { ...record, input } : body;
}
11 changes: 6 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3738,11 +3738,12 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements
}

/**
* Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound
* provider call through the proxy — no per-callsite changes (verified: Bun honors these plus
* NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the
* CLI's own health checks and running-proxy API calls stay direct. Call once per process entry
* that makes outbound provider requests (server start, catalog sync).
* Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports
* such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY
* variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS.
* localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and
* running-proxy API calls stay direct. Call once per process entry that makes outbound provider
* requests (server start, catalog sync).
*/
export function applyProxyEnv(config: OcxConfig): void {
applyProxyEnvWith(config);
Expand Down
47 changes: 2 additions & 45 deletions src/lib/provider-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
resolvePublicAddresses,
} from "./destination-policy";
import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env";
import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env";
import { publicProviderBaseUrl } from "./provider-url";

type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
Expand Down Expand Up @@ -37,10 +37,6 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }>
return addresses.find(address => address.family === 4) ?? addresses[0]!;
}

function configuredProxyFor(): boolean {
return outboundProxyConfigured();
}

/**
* Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode).
*
Expand Down Expand Up @@ -76,45 +72,6 @@ function transparentFakeIpException(
return isCanonicalUrl(name, url);
}

function normalizeProxyHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
return normalized.startsWith("[") && normalized.endsWith("]")
? normalized.slice(1, -1)
: normalized;
}

function noProxyMatches(url: URL): boolean {
const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
const hostname = normalizeProxyHostname(url.hostname);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
for (const rawEntry of raw.split(",")) {
let entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === "*") return true;
entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!;

let entryHost = entry;
let entryPort = "";
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
if (bracketed) {
entryHost = bracketed[1]!;
entryPort = bracketed[2] ?? "";
} else if ((entry.match(/:/g)?.length ?? 0) === 1) {
const separator = entry.lastIndexOf(":");
const possiblePort = entry.slice(separator + 1);
if (/^\d+$/.test(possiblePort)) {
entryHost = entry.slice(0, separator);
entryPort = possiblePort;
}
}
if (entryPort && entryPort !== port) continue;
entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
if (!entryHost) continue;
if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true;
}
return false;
}

let proxyBoundaryWarned = false;
let proxyDnsDegradationWarned = false;

Expand Down Expand Up @@ -181,7 +138,7 @@ async function providerOutboundRequest(
return provider.fetch(url, { ...init, method, redirect: "manual" });
}
const parsed = postUrl ?? new URL(url);
const proxyConfigured = configuredProxyFor();
const proxyConfigured = outboundProxyConfigured();
// Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport
// below reason about the same value. `null` here means "no proxy fetch would actually use",
// even if some other proxy variable is set.
Expand Down
67 changes: 67 additions & 0 deletions src/lib/proxy-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;

export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number];
export type ProxyEnvMap = Record<string, string | undefined>;
export type ProxyRoute =
| { kind: "direct" }
| { kind: "proxy"; proxy: string }
| { kind: "fallback" };

export function normalizeProxyHostname(hostname: string): string {
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
return normalized.startsWith("[") && normalized.endsWith("]")
? normalized.slice(1, -1)
: normalized;
}

export function noProxyMatches(
url: URL,
env: ProxyEnvMap = process.env,
): boolean {
const raw = env.NO_PROXY ?? env.no_proxy ?? "";
const hostname = normalizeProxyHostname(url.hostname);
const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80");
for (const rawEntry of raw.split(",")) {
let entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === "*") return true;
entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!;

let entryHost = entry;
let entryPort = "";
const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
if (bracketed) {
entryHost = bracketed[1]!;
entryPort = bracketed[2] ?? "";
} else if ((entry.match(/:/g)?.length ?? 0) === 1) {
const separator = entry.lastIndexOf(":");
const possiblePort = entry.slice(separator + 1);
if (/^\d+$/.test(possiblePort)) {
entryHost = entry.slice(0, separator);
entryPort = possiblePort;
}
}
if (entryPort && entryPort !== port) continue;
entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true;
}
return false;
}

export function resolveProxyRoute(
url: URL,
env: ProxyEnvMap = process.env,
): ProxyRoute {
if (noProxyMatches(url, env)) return { kind: "direct" };
const key = url.protocol === "https:" || url.protocol === "wss:"
? "HTTPS_PROXY"
: "HTTP_PROXY";
const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"]
.map(candidate => env[candidate]?.trim())
.find(Boolean);
if (!proxy) return { kind: "direct" };
try {
const protocol = new URL(proxy).protocol;
return protocol === "http:" || protocol === "https:"
? { kind: "proxy", proxy }
: { kind: "fallback" };
} catch {
return { kind: "fallback" };
}
}

export function proxyEnvPresent(
key: ProxyEnvKey,
Expand Down
11 changes: 11 additions & 0 deletions src/server/responses/agent-task-recovery-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number {
export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } {
return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes };
}

/** Read an existing recovery without starting a request or extending its lifetime. */
export function cachedAgentTaskRecovery(key: string): string | null {
const entry = RECOVERY_CACHE.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
deleteRecoveryCacheEntry(key, entry);
return null;
}
return entry.assignment;
}
Loading
Loading