fix(providers): opt-in OpenCode CLI header synthesis for opencode-go VPS egress (#5997)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 00:00:38 -03:00
parent f1a5a02d31
commit 72f6dcd550
5 changed files with 181 additions and 3 deletions

View File

@@ -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=...

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -32,11 +32,19 @@ function findHeader(headers: Record<string, string>, 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<string, string>,
clientHeaders: Record<string, string>,
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();
}
}
}

View File

@@ -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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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");
});
});
});