From 72f6dcd550ba584f1e93d50692d75124d82e4953 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 5 Jul 2026 00:00:38 -0300 Subject: [PATCH] fix(providers): opt-in OpenCode CLI header synthesis for opencode-go VPS egress (#5997) --- .env.example | 9 ++ CHANGELOG.md | 1 + open-sse/executors/opencode.ts | 30 ++++- open-sse/utils/opencodeHeaders.ts | 31 ++++- ...pencode-cli-headers-synthesis-5997.test.ts | 113 ++++++++++++++++++ 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 tests/unit/opencode-cli-headers-synthesis-5997.test.ts diff --git a/.env.example b/.env.example index 8d738cf30c..d9f80ca4e0 100644 --- a/.env.example +++ b/.env.example @@ -493,6 +493,15 @@ NEXT_PUBLIC_CLOUD_URL= #OPENCODE_GO_AUTH_COOKIE=auth=... #OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=... +# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of +# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers. +# When your clients don't already send them, set this to synthesize the CLI headers +# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on +# absent keys. OFF by default — forward-only is safer when clients already send them. +# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT / +# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default). +#OPENCODE_SYNTHESIZE_CLI_HEADERS=true + # Ollama Cloud quota scraping. Prefer configuring this per connection in # Dashboard → Providers → Ollama Cloud. The cookie is sensitive. #OLLAMA_USAGE_COOKIE=__Secure-session=... diff --git a/CHANGELOG.md b/CHANGELOG.md index 69bed97e57..96933e38c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) - **fix(nodejs):** the default app log path now resolves under `DATA_DIR` (`~/.omniroute/logs/application/app.log`) instead of `process.cwd()` ([#6197](https://github.com/diegosouzapw/OmniRoute/issues/6197)) — the globally-installed CLI runs from an arbitrary working directory, so anchoring the default to cwd made file logging silently write to (or no-op under) an unrelated directory, contradicting the documented `.env.example` default. `getAppLogFilePath()` now computes the default lazily via the pure `resolveDataDir()` resolver (honours a per-process `DATA_DIR`, no directory-creation side effect); an explicit `APP_LOG_FILE_PATH` still wins. Regression guard: `tests/unit/logenv-datadir-path-6197.test.ts` (3). - **fix(docker):** AgentBridge/`startMitm` no longer aborts in containers/headless when the Antigravity-default DNS step can't write `/etc/hosts` ([#6127](https://github.com/diegosouzapw/OmniRoute/issues/6127)), and the privileged command's stderr now reaches `app.log` instead of only a bare exit code hitting the toast ([#6198](https://github.com/diegosouzapw/OmniRoute/issues/6198)). The default DNS step (`addDNSEntry`) was called unguarded while cert install and the two sibling DNS steps were each best-effort — in the runtime Docker image (`USER node`, no `sudo`, read-only `/etc/hosts`) it threw `Command failed with code 1` out of `startMitmInternal` and killed the whole start, discarding the stderr. The three DNS steps are extracted into a best-effort `provisionDnsEntries()` where each failure is logged with the full `err` (stderr included, folded in by `systemCommands.ts`) and never aborts the start. Regression guard: `tests/unit/mitm-dns-graceful-degrade-6127.test.ts` (4). +- **fix(providers):** opencode-go/opencode-zen can now synthesize the OpenCode CLI identity headers Cloudflare requires on VPS egress ([#5997](https://github.com/diegosouzapw/OmniRoute/issues/5997)) — on a datacenter VPS, `opencode.ai/zen/go/v1/chat/completions` 403s (HTML challenge) requests lacking CLI identity, while the reporter's control curl proved that `User-Agent: opencode-cli/1.0.0` + `x-opencode-client: cli` + `x-opencode-project: default` + fresh request/session UUIDs succeed. Opt-in via `OPENCODE_SYNTHESIZE_CLI_HEADERS=true` (values overridable via `OPENCODE_GO_USER_AGENT`/`OPENCODE_USER_AGENT`/`OPENCODE_CLIENT`/`OPENCODE_PROJECT`); it fills only headers the client did not already send. Kept **off by default** — the forward-only path is deliberate (fabricating a wrong value risks upstream rejection; a prior dedup regressed with `opencode/local`), so this replaces the fragile local header-injection shim without changing default behavior. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (6). (thanks @aleksesipenko) ### ⚡ Performance & Infrastructure diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 60bde9d37c..a7e885103f 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -248,9 +248,35 @@ export class OpencodeExecutor extends BaseExecutor { headers["Accept"] = "text/event-stream"; } - if (clientHeaders) { - forwardOpencodeClientHeaders(headers, clientHeaders, { + // Opt-in (#5997): synthesize OpenCode CLI identity headers the client did not send. + // Cloudflare in front of opencode.ai/zen/go 403s server-side (VPS) requests lacking + // CLI identity, but the forward-only default is deliberate — fabricating a WRONG + // value risks upstream rejection (#5720 regressed with "opencode/local"), and this + // is deployment-specific. So it stays OFF by default and the VPS operator enables it + // with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied + // headers always take precedence. + const synthesizeCli = /^(1|true|yes|on)$/i.test( + process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "" + ); + const cliDefaults = synthesizeCli + ? (() => { + const providerId = this.config?.id || this.provider || "opencode"; + const envUAKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; + return { + userAgent: + process.env[envUAKey]?.trim() || + process.env.OPENCODE_USER_AGENT?.trim() || + "opencode-cli/1.0.0", + client: process.env.OPENCODE_CLIENT?.trim() || "cli", + project: process.env.OPENCODE_PROJECT?.trim() || "default", + }; + })() + : undefined; + + if (clientHeaders || cliDefaults) { + forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, { synthesizeRequestId: true, + cliDefaults, }); } diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 17746522a4..a871488c47 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -32,11 +32,19 @@ function findHeader(headers: Record, name: string): string | und * @param options.synthesizeRequestId - When true (OpencodeExecutor only), maps * x-session-affinity / x-session-id to x-opencode-session when the latter is * missing, and synthesizes a UUID for x-opencode-request if also missing. + * @param options.cliDefaults - When provided (OpencodeExecutor only), synthesize + * the OpenCode CLI identity headers that Cloudflare requires on VPS egress + * (User-Agent, x-opencode-client, x-opencode-project) plus fresh request/session + * UUIDs, but ONLY for keys the client did not already supply. Client values always + * win; these defaults only fill gaps. (#5997) */ export function forwardOpencodeClientHeaders( headers: Record, clientHeaders: Record, - options?: { synthesizeRequestId?: boolean } + options?: { + synthesizeRequestId?: boolean; + cliDefaults?: { userAgent: string; client: string; project: string }; + } ): void { // 1. Forward User-Agent const clientUA = clientHeaders["User-Agent"] || clientHeaders["user-agent"]; @@ -64,4 +72,25 @@ export function forwardOpencodeClientHeaders( } } } + + // 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects + // on VPS egress, for any key the client did not supply (#5997). + const cliDefaults = options?.cliDefaults; + if (cliDefaults) { + if (!headers["User-Agent"] && !headers["user-agent"]) { + setUserAgentHeader(headers, cliDefaults.userAgent); + } + if (!headers["x-opencode-client"]) { + headers["x-opencode-client"] = cliDefaults.client; + } + if (!headers["x-opencode-project"]) { + headers["x-opencode-project"] = cliDefaults.project; + } + if (!headers["x-opencode-request"]) { + headers["x-opencode-request"] = randomUUID(); + } + if (!headers["x-opencode-session"]) { + headers["x-opencode-session"] = randomUUID(); + } + } } diff --git a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts new file mode 100644 index 0000000000..ee45237fe1 --- /dev/null +++ b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts @@ -0,0 +1,113 @@ +/** + * Regression test for #5997 — opencode-go/opencode-zen upstream requests must carry + * OpenCode CLI identity headers even when the client did not supply them. + * + * On a datacenter VPS, `opencode.ai/zen/go/v1/chat/completions` is fronted by + * Cloudflare, which 403s (HTML challenge) requests lacking CLI identity. The reporter's + * control curl proved the exact headers that succeed: + * User-Agent: opencode-cli/1.0.0 · x-opencode-client: cli · + * x-opencode-project: default · x-opencode-request/-session: fresh UUIDs + * Forwarding those headers from the client also fixes it — confirming the upstream + * expects CLI identity. Since most OpenAI-compatible clients never send them, + * `OpencodeExecutor.buildHeaders()` must synthesize the defaults when absent. + * + * Client-supplied values always take precedence (defaults only fill gaps), and the + * UA/client/project defaults are env-overridable. + * + * The executor-level synthesis is OPT-IN via `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`: + * the forward-only default is deliberate (fabricating a WRONG value risks upstream + * rejection — #5720 regressed with "opencode/local"), and this is deployment-specific + * (the owner asked for it to stay off-by-default pending live validation). With the flag + * off, buildHeaders keeps the historical forward-only behavior. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { forwardOpencodeClientHeaders } from "../../open-sse/utils/opencodeHeaders.ts"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const CLI_DEFAULTS = { userAgent: "opencode-cli/1.0.0", client: "cli", project: "default" }; + +function withEnv(key: string, value: string | undefined, fn: () => void) { + const saved = process.env[key]; + try { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + fn(); + } finally { + if (saved === undefined) delete process.env[key]; + else process.env[key] = saved; + } +} + +test("forwardOpencodeClientHeaders: cliDefaults synthesize all CLI identity headers when absent [#5997]", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders(headers, {}, { cliDefaults: CLI_DEFAULTS }); + + assert.equal(headers["User-Agent"], "opencode-cli/1.0.0"); + assert.equal(headers["x-opencode-client"], "cli"); + assert.equal(headers["x-opencode-project"], "default"); + assert.match(headers["x-opencode-request"] ?? "", UUID_RE); + assert.match(headers["x-opencode-session"] ?? "", UUID_RE); + assert.notEqual(headers["x-opencode-request"], headers["x-opencode-session"]); +}); + +test("forwardOpencodeClientHeaders: client-supplied CLI headers take precedence over defaults [#5997]", () => { + const headers: Record = {}; + const clientHeaders = { + "User-Agent": "my-tool/9.9", + "x-opencode-client": "vscode", + "x-opencode-project": "acme", + "x-opencode-request": "req-from-client", + "x-opencode-session": "sess-from-client", + }; + forwardOpencodeClientHeaders(headers, clientHeaders, { cliDefaults: CLI_DEFAULTS }); + + assert.equal(headers["User-Agent"], "my-tool/9.9"); + assert.equal(headers["x-opencode-client"], "vscode"); + assert.equal(headers["x-opencode-project"], "acme"); + assert.equal(headers["x-opencode-request"], "req-from-client"); + assert.equal(headers["x-opencode-session"], "sess-from-client"); +}); + +test("forwardOpencodeClientHeaders: without cliDefaults, no synthesis (DefaultExecutor path unchanged)", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders(headers, {}); + assert.equal(headers["User-Agent"], undefined); + assert.equal(headers["x-opencode-client"], undefined); + assert.equal(headers["x-opencode-project"], undefined); +}); + +test("OpencodeExecutor.buildHeaders: forward-only by default — no fabrication when flag is off [#5997]", () => { + withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", undefined, () => { + const executor = new OpencodeExecutor("opencode-go"); + const headers = executor.buildHeaders(null, true, null, "glm-5.2"); + assert.equal(headers["User-Agent"], undefined); + assert.equal(headers["x-opencode-client"], undefined); + assert.equal(headers["x-opencode-project"], undefined); + }); +}); + +test("OpencodeExecutor.buildHeaders: synthesizes CLI defaults with flag on + no client headers [#5997]", () => { + withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", "true", () => { + const executor = new OpencodeExecutor("opencode-go"); + const headers = executor.buildHeaders(null, true, null, "glm-5.2"); + + assert.equal(headers["User-Agent"], "opencode-cli/1.0.0"); + assert.equal(headers["x-opencode-client"], "cli"); + assert.equal(headers["x-opencode-project"], "default"); + assert.match(headers["x-opencode-request"] ?? "", UUID_RE); + assert.match(headers["x-opencode-session"] ?? "", UUID_RE); + }); +}); + +test("OpencodeExecutor.buildHeaders: OPENCODE_GO_USER_AGENT env overrides the default UA (flag on) [#5997]", () => { + withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", "true", () => { + withEnv("OPENCODE_GO_USER_AGENT", "opencode-cli/2.5.0", () => { + const executor = new OpencodeExecutor("opencode-go"); + const headers = executor.buildHeaders(null, true, null, "glm-5.2"); + assert.equal(headers["User-Agent"], "opencode-cli/2.5.0"); + }); + }); +});