mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 14:52:22 +03:00
Compare commits
3 Commits
feat/relay
...
fix/relay-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a325166bf | ||
|
|
f6e30a261e | ||
|
|
88c3a880f5 |
1
changelog.d/fixes/14390-relay-opencode-session.md
Normal file
1
changelog.d/fixes/14390-relay-opencode-session.md
Normal file
@@ -0,0 +1 @@
|
||||
- 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))
|
||||
29
docs/reference/OPENCODE-SESSION-IDENTITY.md
Normal file
29
docs/reference/OPENCODE-SESSION-IDENTITY.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
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.
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
forwardOpencodeClientHeaders,
|
||||
resolveOpencodeCliDefaults,
|
||||
} from "../utils/opencodeHeaders.ts";
|
||||
import { projectOpencodeSessionBody } from "../utils/opencodeSessionIdentity.ts";
|
||||
import {
|
||||
type AccountProxyConfig,
|
||||
type RotatableAccount,
|
||||
@@ -29,7 +30,12 @@ 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,
|
||||
@@ -1065,30 +1071,12 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
gatedScope
|
||||
);
|
||||
|
||||
this._clientSession = clientSuppliedOpencodeSession(clientHeaders);
|
||||
this._clientSession = clientSuppliedOpencodeSession(clientHeaders, body);
|
||||
if (clientHeaders || cliDefaults) {
|
||||
const b = body && typeof body === "object" ? (body as Record<string, unknown>) : null;
|
||||
forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, {
|
||||
synthesizeRequestId: true,
|
||||
cliDefaults,
|
||||
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,
|
||||
sessionBody: projectOpencodeSessionBody(body),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3200,7 +3200,8 @@ export async function handleChatCore({
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
|
||||
clientHeaders: buildExecutorClientHeaders(
|
||||
clientRawRequest?.headers,
|
||||
userAgent
|
||||
userAgent,
|
||||
{ provider, body }
|
||||
),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
@@ -3387,7 +3388,8 @@ export async function handleChatCore({
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
|
||||
clientHeaders: buildExecutorClientHeaders(
|
||||
clientRawRequest?.headers,
|
||||
userAgent
|
||||
userAgent,
|
||||
{ provider, body }
|
||||
),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
@@ -4499,7 +4501,9 @@ export async function handleChatCore({
|
||||
log,
|
||||
extendedContext,
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
|
||||
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
|
||||
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent, {
|
||||
provider, body,
|
||||
}),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry: isCombo,
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* 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;
|
||||
* behaviour is byte-identical to the previous module-level function.
|
||||
* OpenCode additionally retains explicit conversation identity before body translation.
|
||||
*/
|
||||
import { preserveOpencodeSessionIdentity } from "../../utils/opencodeSessionIdentity.ts";
|
||||
|
||||
export function buildExecutorClientHeaders(
|
||||
headers: Headers | Record<string, unknown> | null | undefined,
|
||||
userAgent?: string | null
|
||||
userAgent?: string | null,
|
||||
request?: { provider?: string; body?: unknown }
|
||||
) {
|
||||
const normalized: Record<string, string> = {};
|
||||
const isLeaseControlHeader = (key: string) => {
|
||||
@@ -38,5 +40,6 @@ export function buildExecutorClientHeaders(
|
||||
normalized["User-Agent"] = normalizedUserAgent;
|
||||
}
|
||||
|
||||
preserveOpencodeSessionIdentity(normalized, request);
|
||||
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -35,13 +39,10 @@ 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
|
||||
clientHeaders: Record<string, string> | null | undefined,
|
||||
body?: unknown
|
||||
): string | undefined {
|
||||
if (!clientHeaders) return undefined;
|
||||
const value =
|
||||
findHeader(clientHeaders, "x-opencode-session") ?? findHeader(clientHeaders, "x-session-id");
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
return resolveOpencodeSessionIdentity(clientHeaders, body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,13 +156,7 @@ export function forwardOpencodeClientHeaders(
|
||||
options?: {
|
||||
synthesizeRequestId?: boolean;
|
||||
cliDefaults?: { userAgent: string; client: string; project: string };
|
||||
sessionBody?: {
|
||||
model?: string;
|
||||
system?: unknown;
|
||||
messages?: Array<{ role?: string; content?: unknown }>;
|
||||
input?: Array<{ role?: string; content?: unknown }>;
|
||||
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
||||
};
|
||||
sessionBody?: OpencodeSessionBody;
|
||||
}
|
||||
): void {
|
||||
// 1. Forward User-Agent
|
||||
@@ -187,19 +182,8 @@ export function forwardOpencodeClientHeaders(
|
||||
}
|
||||
|
||||
// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
|
||||
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();
|
||||
}
|
||||
}
|
||||
if (options?.synthesizeRequestId || options?.cliDefaults) {
|
||||
applySessionFallback(headers, clientHeaders, options.sessionBody);
|
||||
}
|
||||
|
||||
// 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects
|
||||
@@ -209,6 +193,21 @@ 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
|
||||
@@ -221,13 +220,7 @@ export function forwardOpencodeClientHeaders(
|
||||
function applyCliDefaults(
|
||||
headers: Record<string, string>,
|
||||
cliDefaults: { userAgent: string; client: string; project: string },
|
||||
sessionBody?: {
|
||||
model?: string;
|
||||
system?: unknown;
|
||||
messages?: Array<{ role?: string; content?: unknown }>;
|
||||
input?: Array<{ role?: string; content?: unknown }>;
|
||||
tools?: Array<{ name?: string; function?: { name?: string } }>;
|
||||
}
|
||||
sessionBody?: OpencodeSessionBody
|
||||
): 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
|
||||
|
||||
105
open-sse/utils/opencodeSessionIdentity.ts
Normal file
105
open-sse/utils/opencodeSessionIdentity.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
107
tests/unit/opencode-native-session-identity.test.ts
Normal file
107
tests/unit/opencode-native-session-identity.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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, {});
|
||||
});
|
||||
69
tests/unit/opencode-session-before-translation.test.ts
Normal file
69
tests/unit/opencode-session-before-translation.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user