Skip to content
41 changes: 37 additions & 4 deletions bin/cli/runtime/nativeDeps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ export function hasModule(name) {
return existsSync(join(runtimeModules(), name, "package.json"));
}

/**
* Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that
* is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault
* the process instead of throwing) never takes down the caller — only the probe subprocess.
*/
function probeNativeBinaryLoadable(binary) {
try {
const res = spawnSync(
process.execPath,
[
"-e",
"try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }",
binary,
],
{ timeout: 10_000, stdio: "ignore" }
);
// status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown
// ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a
// signal, e.g. SIGSEGV) — means the binary is not safe to load.
return res.status === 0;
} catch {
return false;
}
}

export function isBetterSqliteBinaryValid() {
const binary = join(
runtimeModules(),
Expand All @@ -68,10 +93,18 @@ export function isBetterSqliteBinaryValid() {
closeSync(fd);
const magic = buf.toString("hex");
const os = platform();
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
return true;
let formatOk;
if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF
else if (os === "darwin")
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ
else formatOk = true;
if (!formatOk) return false;
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI
// (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header
// check and then crashes (segfault) on load instead of triggering a rebuild. Actually
// attempt to load it, isolated in a subprocess.
return probeNativeBinaryLoadable(binary);
} catch {
return false;
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/2413-preserve-agent-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(executors):** forward agent-supplied `X-Session-ID`/`X-Title` metadata headers to upstream providers — previously dropped for every client outside the `x-opencode-*` allowlist. (thanks @chitholian) (#7104)
1 change: 1 addition & 0 deletions changelog.d/fixes/2493-better-sqlite3-abi-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(cli):** the runtime self-heal now verifies a cached `better-sqlite3` native binary actually loads for the running Node before trusting it — the old check only inspected the file's magic bytes (ELF/Mach-O/PE header), so a binary built for a different Node ABI passed validation and segfaulted the process on first use instead of triggering a rebuild. (thanks @mrprohack) (#7105)
19 changes: 19 additions & 0 deletions open-sse/utils/opencodeHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ const OPENCODE_HEADER_KEYS = [
"x-opencode-client",
] as const;

/**
* Common agent-metadata headers used by non-OpenCode clients (custom agents/
* providers) for upstream request tracking and attribution. Forwarded the same
* way as the x-opencode-* set: case-insensitive lookup, client value wins.
* Added for 9router#2413 — these were previously dropped for every client
* outside the OpenCode allowlist.
*/
const AGENT_METADATA_HEADER_KEYS = ["x-session-id", "x-title"] as const;

/**
* Case-insensitive lookup for a header in a headers record.
*/
Expand All @@ -26,6 +35,8 @@ function findHeader(headers: Record<string, string>, name: string): string | und
* 1. Forwards User-Agent from clientHeaders via `setUserAgentHeader()`
* 2. Forwards x-opencode-session, x-opencode-request, x-opencode-project,
* x-opencode-client headers (case-insensitive match)
* 3. Forwards x-session-id, x-title agent-metadata headers (case-insensitive
* match) — common conventions used by non-OpenCode agent clients (9router#2413)
*
* @param headers - The outbound headers record to mutate
* @param clientHeaders - The client-provided headers to forward from
Expand Down Expand Up @@ -60,6 +71,14 @@ export function forwardOpencodeClientHeaders(
}
}

// 2b. Forward agent-metadata headers (x-session-id, x-title) — 9router#2413
for (const headerName of AGENT_METADATA_HEADER_KEYS) {
const value = findHeader(clientHeaders, headerName);
if (value) {
headers[headerName] = value;
}
}

// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
if (options?.synthesizeRequestId && !headers["x-opencode-session"]) {
const sessionAffinity =
Expand Down
50 changes: 45 additions & 5 deletions tests/unit/cli-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,60 @@ test("buildEnvWithRuntime preserva NODE_PATH existente", async () => {
assert.ok(env.NODE_PATH.includes("/existing/path"), "NODE_PATH original deve ser preservado");
});

test("isBetterSqliteBinaryValid detecta ELF magic bytes (Linux)", async () => {
test("isBetterSqliteBinaryValid rejeita binário com magic bytes válidos mas ABI incompatível (regressão #2493)", async () => {
// Regression for upstream 9router#2493: a binary that only "looks" native (correct ELF/Mach-O/PE
// header) but was built for a different Node ABI (NODE_MODULE_VERSION) must NOT be reported as
// valid — loading it crashes the process (segfault) instead of triggering a rebuild.
const { getRuntimeNodeModules, isBetterSqliteBinaryValid } =
await import("../../bin/cli/runtime/nativeDeps.mjs");
const nm = getRuntimeNodeModules();
const buildDir = join(nm, "better-sqlite3", "build", "Release");
mkdirSync(buildDir, { recursive: true });
const binary = join(buildDir, "better_sqlite3.node");
const buf = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]);
const { platform } = await import("node:os");
const os = platform();
// Correct file-format magic bytes for the current OS, but not a real, loadable native addon —
// this is exactly what the old magic-bytes-only check let through.
const magicByPlatform = {
linux: [0x7f, 0x45, 0x4c, 0x46],
darwin: [0xcf, 0xfa, 0xed, 0xfe],
win32: [0x4d, 0x5a],
};
const magic = magicByPlatform[os] ?? magicByPlatform.linux;
const buf = Buffer.concat([Buffer.from(magic), Buffer.alloc(64, 0)]);
writeFileSync(binary, buf);
const result = isBetterSqliteBinaryValid();
const { platform } = await import("node:os");
if (platform() === "linux") {
assert.equal(result, true, "ELF magic bytes devem ser válidos no Linux");
assert.equal(
result,
false,
"binário com header válido mas ABI/conteúdo incompatível deve ser inválido"
);
rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true });
});

test("isBetterSqliteBinaryValid aceita um binário nativo real e carregável", async () => {
const { getRuntimeNodeModules, isBetterSqliteBinaryValid } =
await import("../../bin/cli/runtime/nativeDeps.mjs");
const { existsSync, copyFileSync } = await import("node:fs");
const realBinary = join(
process.cwd(),
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
if (!existsSync(realBinary)) {
// Ambient runtime without a compiled better-sqlite3 binary — nothing to assert here.
return;
}
const nm = getRuntimeNodeModules();
const buildDir = join(nm, "better-sqlite3", "build", "Release");
mkdirSync(buildDir, { recursive: true });
const binary = join(buildDir, "better_sqlite3.node");
copyFileSync(realBinary, binary);
const result = isBetterSqliteBinaryValid();
assert.equal(result, true, "um binário real, compatível com o Node atual, deve ser válido");
rmSync(join(nm, "better-sqlite3"), { recursive: true, force: true });
});

Expand Down
37 changes: 37 additions & 0 deletions tests/unit/refactor-opencodeHeaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,43 @@ describe("forwardOpencodeClientHeaders – x-opencode-* headers", () => {
});
});

// ── agent metadata headers (X-Session-ID / X-Title) — 9router#2413 ─────────
// Non-OpenCode agent clients (e.g. custom providers) commonly send X-Session-ID
// and X-Title for upstream request tracking/attribution. These were previously
// dropped for every client outside the x-opencode-* allowlist.

describe("forwardOpencodeClientHeaders – X-Session-ID / X-Title", () => {
it("forwards X-Session-ID from client headers", () => {
const headers = h();
const clientHeaders = { "X-Session-ID": "sess-xyz" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-session-id"], "sess-xyz");
});

it("forwards X-Title from client headers", () => {
const headers = h();
const clientHeaders = { "X-Title": "My Agent" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-title"], "My Agent");
});

it("matches X-Session-ID / X-Title case-insensitively", () => {
const headers = h();
const clientHeaders = { "x-session-id": "sess-lower", "x-title": "lower title" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-session-id"], "sess-lower");
assert.equal(headers["x-title"], "lower title");
});

it("still does NOT forward unrelated unknown headers", () => {
const headers = h();
const clientHeaders = { "X-Session-ID": "sess-1", "X-Random-Other": "nope" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-session-id"], "sess-1");
assert.equal(headers["X-Random-Other"], undefined);
});
});

// ── synthesizeRequestId ─────────────────────────────────────────────────────

describe("forwardOpencodeClientHeaders – synthesizeRequestId", () => {
Expand Down
Loading