Compare commits

..

2 Commits

14 changed files with 196 additions and 378 deletions

View File

@@ -0,0 +1 @@
- fix(providers): use shell:true on win32 for zcode .cmd/.bat shim spawn (#13963)

View File

@@ -1 +0,0 @@
- fix(opencode): preserve explicit native and Claude conversation IDs before request translation, keeping the existing canonical session shape and fingerprint fallback. ([#14390](https://github.com/diegosouzapw/OmniRoute/pull/14390))

View File

@@ -1,29 +0,0 @@
---
title: "OpenCode conversation identity"
version: 3.8.51
lastUpdated: 2026-09-21
---
# OpenCode conversation identity
OpenCode and OpenCode Go reuse explicit conversation IDs across turns. The
resolver checks `x-opencode-session`, existing affinity/session headers, native
CLI session/thread headers, request metadata, and then top-level session/thread
fields. Claude's JSON-encoded `metadata.user_id` is inspected for a session ID;
an opaque account/user ID alone is never treated as a conversation ID.
Values containing control characters, empty IDs and IDs longer than 256
characters are ignored. JSON metadata parsing is bounded. Before translation,
the executor header normalizer preserves this identity only for the OpenCode
providers; it does not inject OpenCode headers for other providers.
The existing canonical `ses_` encoding and conversation fingerprint fallback
remain unchanged. With no explicit ID, the first user message and other
existing fingerprint inputs continue to determine continuity. This is not a
new authorization boundary or proof of upstream content isolation.
The implementation lives in `open-sse/utils/opencodeSessionIdentity.ts`,
`open-sse/utils/opencodeHeaders.ts` and
`open-sse/handlers/chatCore/executorClientHeaders.ts`. Regression tests cover
native IDs, Claude metadata, precedence, invalid values and the existing
fingerprint behavior without paid upstream calls.

View File

@@ -20,7 +20,6 @@ import {
forwardOpencodeClientHeaders,
resolveOpencodeCliDefaults,
} from "../utils/opencodeHeaders.ts";
import { projectOpencodeSessionBody } from "../utils/opencodeSessionIdentity.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
@@ -30,12 +29,7 @@ import {
isEmptyUpstreamRejection,
extractChatcmplId,
} from "./accountRotation.ts";
import {
markCooldown,
markOutcome,
markSuccess,
noteResponseServed,
} from "./opencodeAccountHealth.ts";
import { markCooldown, markOutcome, markSuccess, noteResponseServed } from "./opencodeAccountHealth.ts";
import {
isOpencodeFreeTierRefusal,
isOpencodeGeoBlocked,
@@ -1071,12 +1065,30 @@ export class OpencodeExecutor extends BaseExecutor {
gatedScope
);
this._clientSession = clientSuppliedOpencodeSession(clientHeaders, body);
this._clientSession = clientSuppliedOpencodeSession(clientHeaders);
if (clientHeaders || cliDefaults) {
const b = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, {
synthesizeRequestId: true,
cliDefaults,
sessionBody: projectOpencodeSessionBody(body),
sessionBody: b
? {
model: typeof b.model === "string" ? b.model : undefined,
system: b.system,
messages: Array.isArray(b.messages)
? (b.messages as Array<{ role?: string; content?: unknown }>)
: undefined,
// The Responses surface carries the conversation under `input`; without it the
// fingerprint collapses to the model alone and every conversation on that model
// would share one upstream session.
input: Array.isArray(b.input)
? (b.input as Array<{ role?: string; content?: unknown }>)
: undefined,
tools: Array.isArray(b.tools)
? (b.tools as Array<{ name?: string; function?: { name?: string } }>)
: undefined,
}
: undefined,
});
}

View File

@@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { shouldUseShellForCommand } from "@/shared/services/cliRuntime";
const HEADER_SIZE = 13;
const REGULAR_MESSAGE = 1;
@@ -154,6 +155,21 @@ export function encodeZcodeRpcCall(
return frame;
}
/**
* Whether spawn() must go through the shell to launch `command`.
*
* On win32, npm installs global CLI wrappers (e.g. the `zcode`/ZCODE_BIN
* shim) as `.cmd`/`.bat` files. Since Node's CVE-2024-27980 fix, `spawn()`
* refuses to launch a `.cmd`/`.bat` target without `shell: true`, throwing
* ENOENT/EINVAL instead (#13963, same class of bug as #8590/Qoder). The
* bundled ZCODE_SERVER_NODE runtime path spawns a bare `node`/`node.exe`
* binary (no `.cmd`/`.bat` extension) and must keep `shell: false` even on
* win32 — `shouldUseShellForCommand()` already encodes that extension check.
*/
export function shouldUseShellForZcodeCommand(command: string): boolean {
return shouldUseShellForCommand(command);
}
function errorFromPayload(payload: unknown, fallback: string): Error {
if (payload && typeof payload === "object") {
const record = payload as JsonRecord;
@@ -212,7 +228,8 @@ export class ZcodeAppServerClient implements ZcodeClientLike {
cwd: this.cwd,
env: this.env ? { ...process.env, ...this.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
// shell:true on win32 for a .cmd/.bat ZCode shim — see #13963/#8590.
shell: shouldUseShellForZcodeCommand(this.command),
windowsHide: true,
});
} catch (error) {
@@ -260,7 +277,11 @@ export class ZcodeAppServerClient implements ZcodeClientLike {
});
try {
await this.withTimeout(readyPromise, this.startupTimeoutMs, "ZCode app-server handshake timed out");
await this.withTimeout(
readyPromise,
this.startupTimeoutMs,
"ZCode app-server handshake timed out"
);
this.ready = true;
} catch (error) {
await this.disposeChild(child);
@@ -279,9 +300,10 @@ export class ZcodeAppServerClient implements ZcodeClientLike {
this.pendingChunks.push(chunk);
let total = 0;
for (const part of this.pendingChunks) total += part.byteLength;
const buffer = total === chunk.byteLength && this.pendingChunks.length > 0
? chunk
: Buffer.concat(this.pendingChunks);
const buffer =
total === chunk.byteLength && this.pendingChunks.length > 0
? chunk
: Buffer.concat(this.pendingChunks);
this.pendingChunks = [buffer];
if (!this.handshakeDone) {
@@ -307,11 +329,13 @@ export class ZcodeAppServerClient implements ZcodeClientLike {
}
const child = this.child;
if (!child) return;
child.stdin.write(`${JSON.stringify({
type: "zcode-hello-ack",
version: "omniroute",
clientId: `omniroute-${process.pid}`,
})}\n`);
child.stdin.write(
`${JSON.stringify({
type: "zcode-hello-ack",
version: "omniroute",
clientId: `omniroute-${process.pid}`,
})}\n`
);
this.handshakeDone = true;
}
this.consumeFrames();
@@ -366,10 +390,12 @@ export class ZcodeAppServerClient implements ZcodeClientLike {
if (type === RESPONSE_MESSAGE) {
request.resolve(payload);
} else {
request.reject(errorFromPayload(
payload,
type === ERROR_MESSAGE ? "ZCode RPC request failed" : "ZCode RPC request canceled"
));
request.reject(
errorFromPayload(
payload,
type === ERROR_MESSAGE ? "ZCode RPC request failed" : "ZCode RPC request canceled"
)
);
}
}
@@ -437,7 +463,11 @@ export class ZcodeAppServerClient implements ZcodeClientLike {
}
}
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
private async withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([

View File

@@ -512,8 +512,6 @@ export async function handleChatCore({
defaultThinkingEffort,
});
let { provider, model, extendedContext } = modelInfo;
const getExecutorClientHeaders = () =>
buildExecutorClientHeaders(clientRawRequest?.headers, userAgent, { provider, body });
// Keep the selected rule across format conversion, retries and refreshed credentials.
// Each combo leg gets its own execution context; nothing is written to shared accounts.
const reasoningRuleDirective = body?._omnirouteReasoningRule;
@@ -3200,7 +3198,10 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: getExecutorClientHeaders(),
clientHeaders: buildExecutorClientHeaders(
clientRawRequest?.headers,
userAgent
),
clientResponseFormat,
onCredentialsRefreshed,
skipUpstreamRetry,
@@ -3384,7 +3385,10 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: getExecutorClientHeaders(),
clientHeaders: buildExecutorClientHeaders(
clientRawRequest?.headers,
userAgent
),
clientResponseFormat,
onCredentialsRefreshed,
skipUpstreamRetry,
@@ -4495,7 +4499,7 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
clientHeaders: getExecutorClientHeaders(),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
clientResponseFormat,
onCredentialsRefreshed,
skipUpstreamRetry: isCombo,

View File

@@ -5,14 +5,12 @@
* Pure helper extracted from chatCore: normalizes a Headers instance or a plain header object into a
* lowercased-tolerant Record<string,string>, and backfills the client User-Agent (both casings) when
* one is supplied and not already present. Returns null when nothing was collected. Side-effect-free;
* OpenCode additionally retains explicit conversation identity before body translation.
* behaviour is byte-identical to the previous module-level function.
*/
import { preserveOpencodeSessionIdentity } from "../../utils/opencodeSessionIdentity.ts";
export function buildExecutorClientHeaders(
headers: Headers | Record<string, unknown> | null | undefined,
userAgent?: string | null,
request?: { provider?: string; body?: unknown }
userAgent?: string | null
) {
const normalized: Record<string, string> = {};
const isLeaseControlHeader = (key: string) => {
@@ -40,6 +38,5 @@ export function buildExecutorClientHeaders(
normalized["User-Agent"] = normalizedUserAgent;
}
preserveOpencodeSessionIdentity(normalized, request);
return Object.keys(normalized).length > 0 ? normalized : null;
}

View File

@@ -1,10 +1,6 @@
import { createHash, randomBytes, randomUUID } from "crypto";
import { setUserAgentHeader } from "../executors/base.ts";
import { generateSessionId } from "../services/sessionManager.ts";
import {
resolveOpencodeSessionIdentity,
type OpencodeSessionBody,
} from "./opencodeSessionIdentity.ts";
/**
* Default synthesized User-Agent. The upstream only parses the version, so this literal
@@ -39,10 +35,13 @@ export function satisfiesOpencodeUserAgentContract(userAgent: string | null | un
* follows it differ — including in their tool list, which is the very thing being joined.
*/
export function clientSuppliedOpencodeSession(
clientHeaders: Record<string, string> | null | undefined,
body?: unknown
clientHeaders: Record<string, string> | null | undefined
): string | undefined {
return resolveOpencodeSessionIdentity(clientHeaders, body);
if (!clientHeaders) return undefined;
const value =
findHeader(clientHeaders, "x-opencode-session") ?? findHeader(clientHeaders, "x-session-id");
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
/**
@@ -156,7 +155,13 @@ export function forwardOpencodeClientHeaders(
options?: {
synthesizeRequestId?: boolean;
cliDefaults?: { userAgent: string; client: string; project: string };
sessionBody?: OpencodeSessionBody;
sessionBody?: {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
};
}
): void {
// 1. Forward User-Agent
@@ -182,8 +187,19 @@ export function forwardOpencodeClientHeaders(
}
// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
if (options?.synthesizeRequestId || options?.cliDefaults) {
applySessionFallback(headers, clientHeaders, options.sessionBody);
if (options?.synthesizeRequestId && !headers["x-opencode-session"]) {
const sessionAffinity =
findHeader(clientHeaders, "x-session-affinity") || findHeader(clientHeaders, "x-session-id");
if (sessionAffinity) {
// Kept as-is here. When identity synthesis is on, applyCliDefaults renders it in the
// canonical shape below; with the synthesis opted out this path stays byte-identical
// to before, since opting out means no fabricated identity at all.
headers["x-opencode-session"] = sessionAffinity;
if (!headers["x-opencode-request"]) {
headers["x-opencode-request"] = randomUUID();
}
}
}
// 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects
@@ -193,21 +209,6 @@ export function forwardOpencodeClientHeaders(
}
}
/** Fill missing session/request identity without changing the CLI synthesis policy. */
function applySessionFallback(
headers: Record<string, string>,
clientHeaders: Record<string, string>,
sessionBody?: OpencodeSessionBody
): void {
if (headers["x-opencode-session"]) return;
const sessionAffinity = resolveOpencodeSessionIdentity(clientHeaders, sessionBody);
if (!sessionAffinity) return;
// Keep the caller's identity as-is when CLI synthesis is disabled; applyCliDefaults
// renders it in the canonical shape only when that policy is enabled.
headers["x-opencode-session"] = sessionAffinity;
headers["x-opencode-request"] ||= randomUUID();
}
/**
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For
* x-opencode-* headers, client values always win (defaults only fill gaps). The
@@ -220,7 +221,13 @@ function applySessionFallback(
function applyCliDefaults(
headers: Record<string, string>,
cliDefaults: { userAgent: string; client: string; project: string },
sessionBody?: OpencodeSessionBody
sessionBody?: {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
}
): void {
// A client User-Agent is kept only when it already satisfies the upstream contract.
// The previous rule kept anything starting with `opencode-cli/`, which carries no

View File

@@ -1,105 +0,0 @@
/** Explicit conversation identity, before the existing fingerprint fallback. */
const HEADER_NAMES = [
"x-opencode-session",
"x-session-affinity",
"x-session-id",
"x-claude-code-session-id",
"session_id",
"session-id",
"x-session_id",
"thread_id",
"thread-id",
"x-thread-id",
] as const;
const BODY_NAMES = [
"session_id",
"sessionId",
"thread_id",
"threadId",
"conversation_id",
"conversationId",
];
function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function identity(value: unknown): string | undefined {
if (typeof value !== "string" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))
return undefined;
return value.trim() || undefined;
}
function bodyIdentity(value: unknown): string | undefined {
const input = record(value);
if (!input) return undefined;
for (const key of BODY_NAMES) {
const id = identity(input[key]);
if (id) return id;
}
return undefined;
}
function claudeIdentity(value: unknown): string | undefined {
if (typeof value !== "string" || value.length > 4096) return undefined;
try {
return bodyIdentity(JSON.parse(value));
} catch {
return undefined;
}
}
/** Headers win over metadata; an opaque user/account ID is never a session ID. */
export function resolveOpencodeSessionIdentity(
headers: Record<string, string> | null | undefined,
body?: unknown
): string | undefined {
const normalized = new Map(
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value])
);
for (const name of HEADER_NAMES) {
const id = identity(normalized.get(name));
if (id) return id;
}
const input = record(body);
const metadata = record(input?.metadata);
return bodyIdentity(metadata) || claudeIdentity(metadata?.user_id) || bodyIdentity(input);
}
export function preserveOpencodeSessionIdentity(
headers: Record<string, string>,
request?: { provider?: string; body?: unknown }
): void {
if (request?.provider !== "opencode" && request?.provider !== "opencode-go") return;
const sessionId = resolveOpencodeSessionIdentity(headers, request.body);
if (sessionId) headers["x-opencode-session"] = sessionId;
}
export interface OpencodeSessionBody {
model?: string;
system?: unknown;
messages?: Array<{ role?: string; content?: unknown }>;
input?: Array<{ role?: string; content?: unknown }>;
tools?: Array<{ name?: string; function?: { name?: string } }>;
metadata?: unknown;
session_id?: unknown;
thread_id?: unknown;
}
/** Keep only fingerprint/identity inputs; never add this projection to an upstream body. */
export function projectOpencodeSessionBody(body: unknown): OpencodeSessionBody | undefined {
const input = record(body);
if (!input) return undefined;
return {
model: typeof input.model === "string" ? input.model : undefined,
system: input.system,
messages: Array.isArray(input.messages) ? input.messages : undefined,
input: Array.isArray(input.input) ? input.input : undefined,
tools: Array.isArray(input.tools) ? input.tools : undefined,
metadata: input.metadata,
session_id: input.session_id,
thread_id: input.thread_id,
};
}

View File

@@ -90,18 +90,18 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
// The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts
// (god-file decomposition), so the FP moved with the getMiniMaxUsage signature.
//
// open-sse/executors/zcodeProtocol.ts L313: `clientId: \`omniroute-${process.pid}\``
// open-sse/executors/zcodeProtocol.ts L336: `clientId: \`omniroute-${process.pid}\``
// is the per-process identifier in the local ZCode app-server handshake. It is
// generated from the process PID, is not an upstream OAuth/client credential, and
// must remain visible in the wire contract. Frozen by file:line:value key.
// NOTE: the key includes the LINE, so any edit that shifts this statement breaks
// the gate twice over — a stale-entry error plus a "new violation" for the same
// literal. That is what happened here (L302 -> L313). Re-point the line; do not
// remove the entry.
// literal. That happened again at L313 -> L336 (#13963, win32 shell:true spawn
// fix). Re-point the line; do not remove the entry.
export const KNOWN_LITERAL_CREDS = new Set([
"open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
"open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
"open-sse/executors/zcodeProtocol.ts:313:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential
"open-sse/executors/zcodeProtocol.ts:336:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential
]);
/**

View File

@@ -68,10 +68,10 @@ test("allowlist freezes a literal by file:line:value key", () => {
});
test("allowlist preserves the local ZCode handshake client ID without weakening credential detection", () => {
// 312 newlines puts the statement on line 313, which is where it lives in
// 335 newlines puts the statement on line 336, which is where it lives in
// zcodeProtocol.ts today. The allowlist key carries the line number, so this
// literal has to be kept in step with the source (it moved 302 -> 313).
const src = `${"\n".repeat(312)}clientId: \`omniroute-\${process.pid}\`,`;
// literal has to be kept in step with the source (it moved 302 -> 313 -> 336).
const src = `${"\n".repeat(335)}clientId: \`omniroute-\${process.pid}\`,`;
assert.deepEqual(
findLiteralCreds(src, KNOWN_LITERAL_CREDS, "open-sse/executors/zcodeProtocol.ts"),
[]

View File

@@ -1,107 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clientSuppliedOpencodeSession,
forwardOpencodeClientHeaders,
} from "../../open-sse/utils/opencodeHeaders.ts";
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
const defaults = { userAgent: "opencode/1.18.31", client: "desktop", project: "global" };
const body = { model: "big-pickle", messages: [{ role: "user", content: "same prompt" }] };
function session(headers: Record<string, string>) {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(output, headers, {
synthesizeRequestId: true,
cliDefaults: defaults,
sessionBody: body,
});
return output["x-opencode-session"];
}
for (const header of ["session_id", "thread_id", "x-claude-code-session-id"]) {
test(`${header} separates conversations with identical prompts`, () => {
assert.notEqual(
session({ [header]: "conversation-a" }),
session({ [header]: "conversation-b" })
);
assert.equal(session({ [header]: "conversation-a" }), session({ [header]: "conversation-a" }));
assert.equal(clientSuppliedOpencodeSession({ [header]: "conversation-a" }), "conversation-a");
});
}
test("explicit OpenCode session wins over native aliases", () => {
assert.equal(
session({ "x-opencode-session": "explicit", thread_id: "other" }),
session({ "x-opencode-session": "explicit" })
);
});
test("executor carries Claude metadata identity to upstream and tool cache", () => {
const executor = new OpencodeExecutor("opencode-go");
const build = (id: string) =>
executor.buildHeaders(
null,
true,
null,
body.model,
{},
{
...body,
metadata: { user_id: JSON.stringify({ session_id: id }) },
}
);
assert.notEqual(
build("conversation-a")["x-opencode-session"],
build("conversation-b")["x-opencode-session"]
);
assert.equal(executor._clientSession, "conversation-b");
});
test("native aliases never add OpenCode headers to a generic forwarding call", () => {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(output, { thread_id: "conversation-a" });
assert.equal(output["x-opencode-session"], undefined);
});
test("untrusted native IDs reject controls and excessive length", () => {
for (const id of ["bad\nheader", "bad\u0000header", "x".repeat(257)]) {
assert.equal(clientSuppliedOpencodeSession({ thread_id: id }), undefined);
}
});
test("native fallback preserves raw identity and an existing request when CLI synthesis is off", () => {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(
output,
{ thread_id: "native-conversation", "x-opencode-request": "existing-request" },
{ synthesizeRequestId: true }
);
assert.equal(output["x-opencode-session"], "native-conversation");
assert.equal(output["x-opencode-request"], "existing-request");
});
test("an existing outbound session bypasses fallback request synthesis", () => {
const output = { "x-opencode-session": "existing-session" } as Record<string, string>;
forwardOpencodeClientHeaders(
output,
{ thread_id: "native-conversation" },
{
synthesizeRequestId: true,
}
);
assert.deepEqual(output, { "x-opencode-session": "existing-session" });
});
test("invalid body identity does not synthesize a request without CLI defaults", () => {
const output: Record<string, string> = {};
forwardOpencodeClientHeaders(
output,
{},
{
synthesizeRequestId: true,
sessionBody: { metadata: { session_id: "bad\nidentity" } },
}
);
assert.deepEqual(output, {});
});

View File

@@ -1,69 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExecutorClientHeaders } from "../../open-sse/handlers/chatCore/executorClientHeaders.ts";
import { resolveOpencodeSessionIdentity } from "../../open-sse/utils/opencodeSessionIdentity.ts";
test("original Claude metadata survives executor header normalization before translation", () => {
const body = { metadata: { user_id: JSON.stringify({ session_id: "claude-conversation" }) } };
const result = buildExecutorClientHeaders({}, "claude-cli", { provider: "opencode-go", body });
assert.equal(result?.["x-opencode-session"], "claude-conversation");
});
test("body identity is not forwarded for another provider", () => {
const result = buildExecutorClientHeaders({}, undefined, {
provider: "openai",
body: { thread_id: "private" },
});
assert.equal(result, null);
});
test("OpenCode headers preserve native identity and strip lease-control headers", () => {
const result = buildExecutorClientHeaders(
new Headers({ Session_Id: "native-conversation", "x-omniroute-lease-owner": "private-owner" }),
undefined,
{ provider: "opencode" }
);
assert.equal(result?.["x-opencode-session"], "native-conversation");
assert.equal(result?.["x-omniroute-lease-owner"], undefined);
});
test("malformed or account-only Claude metadata never becomes conversation identity", () => {
for (const user_id of ["bad-json", JSON.stringify({ account_id: "user" }), "x".repeat(4097)]) {
const result = buildExecutorClientHeaders({}, undefined, {
provider: "opencode-go",
body: { metadata: { user_id } },
});
assert.equal(result, null);
}
});
test("identity precedence is explicit headers, native headers, metadata, then body", () => {
const body = { metadata: { session_id: "metadata" }, thread_id: "body" };
assert.equal(
resolveOpencodeSessionIdentity({ "X-OPENCODE-SESSION": "explicit", thread_id: "native" }, body),
"explicit"
);
assert.equal(resolveOpencodeSessionIdentity({ thread_id: "native" }, body), "native");
assert.equal(resolveOpencodeSessionIdentity({}, body), "metadata");
assert.equal(resolveOpencodeSessionIdentity(null, { thread_id: "body" }), "body");
});
test("invalid identities are ignored without inventing an account-scoped session", () => {
for (const invalid of [null, false, 42, [], {}, "", "\n", "bad\u007fvalue", "x".repeat(257)]) {
assert.equal(
resolveOpencodeSessionIdentity({}, { metadata: { session_id: invalid } }),
undefined
);
}
assert.equal(
resolveOpencodeSessionIdentity({}, { metadata: { user_id: "account-name" } }),
undefined
);
assert.equal(
resolveOpencodeSessionIdentity(
{},
{ metadata: { user_id: JSON.stringify({ session_id: "valid" }) } }
),
"valid"
);
});

View File

@@ -0,0 +1,78 @@
/**
* Regression test for #13963 — Windows: `zc`/`zcode` provider always fails
* with `spawn zcode ENOENT` even when ZCODE_BIN is set.
*
* Root cause: ZcodeAppServerClient.start() (open-sse/executors/zcodeProtocol.ts)
* spawns the ZCode CLI with a hardcoded `shell: false`, regardless of
* process.platform or the file extension of the resolved command. On
* Windows, npm installs global CLI wrappers as `.cmd`/`.bat` shims, and
* since Node's CVE-2024-27980 fix, `spawn()` refuses to launch a `.cmd`/
* `.bat` target without `shell: true`, throwing ENOENT/EINVAL instead. This
* is the same class of bug as #8590 (Qoder), already fixed elsewhere in
* this repo (devin-cli.ts, auggie.ts, cliRuntime.ts's
* shouldUseShellForCommand()).
*
* `shouldUseShellForZcodeCommand()` is a small, pure, exported helper so
* this can be asserted directly without needing to intercept the live ESM
* `spawn` binding (node:child_process.spawn is unmockable via mock.method()
* without --experimental-test-module-mocks, which is not enabled in
* `npm run test:unit` — see tests/unit/windows-hide-child-process-spawns-8131.test.ts).
*/
import test from "node:test";
import assert from "node:assert/strict";
const { shouldUseShellForZcodeCommand } =
await import("@omniroute/open-sse/executors/zcodeProtocol");
/** Temporarily override process.platform for the duration of `fn`. */
function withPlatform<T>(platform: string, fn: () => T): T {
const original = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: platform, configurable: true });
try {
return fn();
} finally {
Object.defineProperty(process, "platform", original);
}
}
test("shouldUseShellForZcodeCommand returns true on win32 for a .cmd shim", () => {
const result = withPlatform("win32", () =>
shouldUseShellForZcodeCommand("C:\\Users\\aaaaa\\AppData\\Roaming\\npm\\zcode.cmd")
);
assert.equal(
result,
true,
"spawn() must use shell:true on win32 when the resolved ZCode binary is a " +
".cmd/.bat shim, or launching it throws ENOENT/EINVAL (Node CVE-2024-27980 fix) " +
"— see #8590 (Qoder) for the same class of bug already fixed elsewhere in this repo"
);
});
test("shouldUseShellForZcodeCommand returns true on win32 for a .bat shim", () => {
const result = withPlatform("win32", () =>
shouldUseShellForZcodeCommand("C:\\Users\\aaaaa\\AppData\\Roaming\\npm\\zcode.bat")
);
assert.equal(result, true);
});
test("shouldUseShellForZcodeCommand stays false on win32 for the bundled node runtime path", () => {
// The ZCODE_SERVER_NODE bundled-runtime path (open-sse/executors/zcode.ts:96-101)
// spawns a bare `node`/`node.exe` binary, not a .cmd/.bat shim — must stay shell:false.
const result = withPlatform("win32", () =>
shouldUseShellForZcodeCommand("C:\\Users\\aaaaa\\.zcode\\server\\node.exe")
);
assert.equal(result, false);
});
test("shouldUseShellForZcodeCommand returns false on linux even for a .cmd-named command", () => {
const result = withPlatform("linux", () =>
shouldUseShellForZcodeCommand("/usr/local/bin/zcode.cmd")
);
assert.equal(result, false);
});
test("shouldUseShellForZcodeCommand returns false on darwin for the plain zcode binary", () => {
const result = withPlatform("darwin", () => shouldUseShellForZcodeCommand("zcode"));
assert.equal(result, false);
});