mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
fix(providers): opt-in OpenCode CLI header synthesis for opencode-go VPS egress (#5997)
This commit is contained in:
@@ -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=...
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
tests/unit/opencode-cli-headers-synthesis-5997.test.ts
Normal file
113
tests/unit/opencode-cli-headers-synthesis-5997.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user