diff --git a/crates/host-core/src/providers/validation.rs b/crates/host-core/src/providers/validation.rs index 13172497f..2ddfc93cb 100644 --- a/crates/host-core/src/providers/validation.rs +++ b/crates/host-core/src/providers/validation.rs @@ -22,6 +22,7 @@ const FORBIDDEN_HEADER_KEYS: &[&str] = &[ "api-key", "chatgpt-account-id", "x-opencode-session", + "x-opencode-request", ]; pub(crate) fn valid_header_key(key: &str) -> bool { diff --git a/packages/agent-runtime/src/compaction-request.test.ts b/packages/agent-runtime/src/compaction-request.test.ts index 1702b1dfa..c06f50377 100644 --- a/packages/agent-runtime/src/compaction-request.test.ts +++ b/packages/agent-runtime/src/compaction-request.test.ts @@ -3,8 +3,10 @@ import type { AssistantMessage, Api, Model, Models, ModelsSimpleStreamOptions } import { OPENCODE_CLIENT_HEADER, OPENCODE_CLIENT_VALUE, + OPENCODE_REQUEST_HEADER, OPENCODE_SESSION_HEADER, OPENCODE_USER_AGENT, + deriveOpenCodeSessionId, } from "./opencode-session-headers.js"; import { compactionRequestOptions, @@ -51,9 +53,12 @@ describe("compactionRequestOptions", () => { expect(options.sessionId).toBe("session-1"); expect(options.maxTokens).toBe(4_096); expect(options.headers).toMatchObject({ - [OPENCODE_SESSION_HEADER]: "session-1", + [OPENCODE_SESSION_HEADER]: deriveOpenCodeSessionId("session-1"), [OPENCODE_CLIENT_HEADER]: OPENCODE_CLIENT_VALUE, "User-Agent": OPENCODE_USER_AGENT, + [OPENCODE_REQUEST_HEADER]: expect.stringMatching( + /^msg_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ), }); }); @@ -130,7 +135,7 @@ describe("withCompactionRequestHeaders", () => { expect(completeSimple.mock.calls[0]?.[2]).toMatchObject({ sessionId: "session-1", maxTokens: 4_096, - headers: { [OPENCODE_SESSION_HEADER]: "session-1" }, + headers: { [OPENCODE_SESSION_HEADER]: deriveOpenCodeSessionId("session-1") }, }); // Every other member is still the collection's own. expect(wrapped.getModel("row-uuid", "glm-5.3-flash")).toBe(model); @@ -307,5 +312,5 @@ describe("compaction summary conversation key", () => { expect(new Set(reports.map(usage => usage.operationId)).size).toBe(2); expect(reports[0]).toMatchObject({ providerId: provider.id, modelId: model.id, totalTokens: 15, costStatus: "unknown" }); expect((failed.usage as unknown as { desktopUsage?: { operationId?: string } }).desktopUsage?.operationId).toBe(reports[0]?.operationId); - expect(completeSimple.mock.calls[0]?.[2]).toMatchObject({ sessionId: "summary-session", headers: { [OPENCODE_SESSION_HEADER]: "summary-session" } }); + expect(completeSimple.mock.calls[0]?.[2]).toMatchObject({ sessionId: "summary-session", headers: { [OPENCODE_SESSION_HEADER]: deriveOpenCodeSessionId("summary-session") } }); }); diff --git a/packages/agent-runtime/src/opencode-free-gate.test.ts b/packages/agent-runtime/src/opencode-free-gate.test.ts new file mode 100644 index 000000000..322dc3429 --- /dev/null +++ b/packages/agent-runtime/src/opencode-free-gate.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createOpenCodeFreeGateFetch, + gateOpenCodeFreeBody, + isOpenCodeFreeTierModelId, + isOpenCodeZenRequest, + withOpenCodeFreeGate, +} from "./opencode-free-gate.js"; + +const CHAT_BODY = { + model: "mimo-v2.5-free", + messages: [{ role: "user", content: "hi" }], + stream: true, +}; + +describe("isOpenCodeFreeTierModelId", () => { + it("matches -free ids case-insensitively", () => { + expect(isOpenCodeFreeTierModelId("mimo-v2.5-free")).toBe(true); + expect(isOpenCodeFreeTierModelId(" Muse-Spark-1.3-Contributor-Free ")).toBe(true); + expect(isOpenCodeFreeTierModelId("gpt-5")).toBe(false); + expect(isOpenCodeFreeTierModelId(undefined)).toBe(false); + }); +}); + +describe("isOpenCodeZenRequest", () => { + it("matches the zen host and subdomains over https only", () => { + expect(isOpenCodeZenRequest("https://opencode.ai/zen/v1/chat/completions")).toBe(true); + expect(isOpenCodeZenRequest("https://us.opencode.ai/zen/v1/models")).toBe(true); + expect(isOpenCodeZenRequest("http://opencode.ai/zen/v1/chat/completions")).toBe(false); + expect(isOpenCodeZenRequest("https://openrouter.ai/api/v1/chat/completions")).toBe(false); + expect(isOpenCodeZenRequest("not a url")).toBe(false); + }); +}); + +describe("withOpenCodeFreeGate", () => { + it("merges marker tools and a system prompt into chat bodies", () => { + const { payload, changed } = withOpenCodeFreeGate({ ...CHAT_BODY }); + expect(changed).toBe(true); + const body = payload as Record; + expect(body.stream).toBe(true); + const messages = body.messages as Array>; + expect(messages[0]).toEqual({ role: "system", content: expect.any(String) }); + const names = (body.tools as Array>).map( + (tool) => (tool.function as Record).name, + ); + expect(names).toEqual(expect.arrayContaining(["read", "write", "edit", "bash"])); + }); + + it("keeps the caller's real tools and never flips stream", () => { + const realTool = { + type: "function", + function: { + name: "Read", + description: "read a file", + parameters: { type: "object", properties: {} }, + }, + }; + const { payload } = withOpenCodeFreeGate({ + model: "mimo-v2.5-free", + messages: [{ role: "system", content: "s" }, { role: "user", content: "hi" }], + tools: [realTool], + stream: false, + }); + const body = payload as Record; + expect(body.stream).toBe(false); + expect(body.tools).toEqual( + expect.arrayContaining([realTool, expect.objectContaining({ type: "function" })]), + ); + // Existing system prompt is kept, not duplicated. + expect((body.messages as unknown[]).filter( + (message) => (message as Record).role === "system", + )).toHaveLength(1); + }); + + it("gates responses bodies with instructions and the responses envelope", () => { + const { payload, changed } = withOpenCodeFreeGate({ + model: "muse-spark-1.3-contributor-free", + instructions: " ", + input: [{ role: "user", content: "hi" }], + stream: true, + }); + expect(changed).toBe(true); + const body = payload as Record; + expect(body.instructions).toBe("You are a helpful coding assistant."); + expect(body.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "function", name: "read" }), + expect.objectContaining({ type: "function", name: "bash" }), + ]), + ); + }); + + it("passes malformed or foreign payloads through untouched", () => { + expect(withOpenCodeFreeGate({ model: "x", tools: "nope" }).changed).toBe(false); + expect(withOpenCodeFreeGate("text").changed).toBe(false); + expect(withOpenCodeFreeGate({ model: "x" }).changed).toBe(false); + }); +}); + +describe("gateOpenCodeFreeBody", () => { + it("reports gated, unchanged, and incomplete", () => { + expect(gateOpenCodeFreeBody(JSON.stringify(CHAT_BODY)).status).toBe("gated"); + const gated = gateOpenCodeFreeBody(JSON.stringify(CHAT_BODY)); + expect(gated.status).toBe("gated"); + // Gating twice is stable: the second pass finds everything present. + if (gated.status === "gated") { + expect(gateOpenCodeFreeBody(gated.body).status).toBe("unchanged"); + } + expect(gateOpenCodeFreeBody("not json").status).toBe("incomplete"); + expect(gateOpenCodeFreeBody(undefined).status).toBe("incomplete"); + expect(gateOpenCodeFreeBody(JSON.stringify({ model: "x" })).status).toBe("incomplete"); + }); +}); + +describe("createOpenCodeFreeGateFetch", () => { + function mockBase() { + const calls: Array<{ input: unknown; init: unknown }> = []; + const base = vi.fn(async (input: unknown, init: unknown) => { + calls.push({ input, init }); + return new Response("{}"); + }); + return { calls, base }; + } + + it("gates zen free-tier bodies and passes the rest through", async () => { + const { calls, base } = mockBase(); + const fetch = createOpenCodeFreeGateFetch(base as never); + await fetch("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + body: JSON.stringify(CHAT_BODY), + }); + expect(base).toHaveBeenCalledTimes(1); + const sent = JSON.parse(String((calls[0]?.init as Record).body)); + expect(sent.tools).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "function" })]), + ); + expect(sent.messages[0].role).toBe("system"); + }); + + it("leaves paid models and foreign hosts byte-identical", async () => { + const { calls, base } = mockBase(); + const fetch = createOpenCodeFreeGateFetch(base as never); + const paidBody = JSON.stringify({ ...CHAT_BODY, model: "gpt-5" }); + await fetch("https://opencode.ai/zen/v1/chat/completions", { + method: "POST", + body: paidBody, + }); + await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + body: JSON.stringify(CHAT_BODY), + }); + expect(calls[0]?.init).toMatchObject({ body: paidBody }); + expect(calls[1]?.input).toBe("https://openrouter.ai/api/v1/chat/completions"); + }); +}); diff --git a/packages/agent-runtime/src/opencode-free-gate.ts b/packages/agent-runtime/src/opencode-free-gate.ts new file mode 100644 index 000000000..fd1c2e283 --- /dev/null +++ b/packages/agent-runtime/src/opencode-free-gate.ts @@ -0,0 +1,236 @@ +/** + * Free-tier body gate for the OpenCode Zen lane (`*-free` models). + * + * The gateway only answers requests that look like the native opencode CLI: + * besides the `ses_`/`msg_` ids and `cli` identity (see + * `./opencode-session-headers.js`), agent turns must carry lowercase marker + * tools and a non-empty system prompt. pi-ai builds request bodies internally, + * so the gate runs as a `fetch` wrapper: it parses the outgoing JSON, merges + * the missing pieces in the protocol-correct envelope, and passes everything + * else through byte-identical. Paid models and non-OpenCode hosts are never + * touched. + */ + +import type { FetchFunction } from "@earendil-works/pi-ai"; + +const OPENCODE_ROOT_DOMAIN = "opencode.ai"; +const FREE_MODEL_SUFFIX = "-free"; +const DEFAULT_SYSTEM_PROMPT = "You are a helpful coding assistant."; + +// Marker tools exist only for lane recognition; the model must not call them. +// They coexist with the caller's real tools (case-mixed passes the gateway). +const MARKER_TOOL_NAMES = ["read", "write", "edit", "bash"] as const; +const MARKER_TOOL_DESCRIPTIONS: Readonly> = { + read: "Do not call this tool. It only marks the request as coming from a coding agent.", + write: "Do not call this tool. It only marks the request as coming from a coding agent.", + edit: "Do not call this tool. It only marks the request as coming from a coding agent.", + bash: "Do not call this tool. It only marks the request as coming from a coding agent.", +}; + +// The `model` field always sits at the top of the payload, so an 8KB probe +// keeps large bodies (images, long context) out of memory. +const MODEL_PROBE_MAX_CHARS = 8192; +const MODEL_FIELD_PATTERN = /"model"\s*:\s*"([^"\\]{1,200})/; +// Bodies past the bound (or not JSON objects) cannot be safely gated; the +// caller passes them through rather than half-signing a request. +const GATE_BODY_MAX_CHARS = 8 * 1024 * 1024; + +export function isOpenCodeFreeTierModelId(modelId: unknown): boolean { + if (typeof modelId !== "string") return false; + return modelId.trim().toLowerCase().endsWith(FREE_MODEL_SUFFIX); +} + +export function isOpenCodeZenRequest(requestUrl: string): boolean { + let parsed: URL; + try { + parsed = new URL(requestUrl); + } catch { + return false; + } + if (parsed.protocol !== "https:") return false; + const hostname = parsed.hostname.toLowerCase(); + return ( + hostname === OPENCODE_ROOT_DOMAIN || + hostname.endsWith(`.${OPENCODE_ROOT_DOMAIN}`) + ); +} + +function toolNames(tools: unknown[]): Set { + const names = new Set(); + for (const tool of tools) { + if (!tool || typeof tool !== "object") continue; + const direct = (tool as { name?: unknown }).name; + if (typeof direct === "string") names.add(direct); + const nested = (tool as { function?: unknown }).function; + if (nested && typeof nested === "object") { + const nestedName = (nested as { name?: unknown }).name; + if (typeof nestedName === "string") names.add(nestedName); + } + } + return names; +} + +function hasMarkerTools(payload: Record): boolean { + if (!Array.isArray(payload.tools)) return false; + const names = toolNames(payload.tools as unknown[]); + return MARKER_TOOL_NAMES.every((name) => names.has(name)); +} + +function markerTool(chat: boolean, name: string): Record { + const description = MARKER_TOOL_DESCRIPTIONS[name]; + const parameters = { type: "object", properties: {} }; + return chat + ? { type: "function", function: { name, description, parameters } } + : { type: "function", name, description, parameters }; +} + +function nonEmptyText(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + return value; +} + +/** + * Merge the missing lowercase marker tools plus a non-empty system prompt + * into a free-tier payload. Malformed `tools` are left for the gateway to + * reject; `stream` and every other field pass through untouched. + */ +export function withOpenCodeFreeGate(payload: T): { payload: T; changed: boolean } { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { payload, changed: false }; + } + const record = payload as Record; + if (record.tools !== undefined && !Array.isArray(record.tools)) { + return { payload, changed: false }; + } + const chat = Array.isArray(record.messages); + const responses = !chat && (typeof record.instructions === "string" || Array.isArray(record.input)); + if (!chat && !responses) return { payload, changed: false }; + + let changed = false; + const next: Record = { ...(record as Record) }; + + const tools = Array.isArray(record.tools) ? [...(record.tools as unknown[])] : []; + const present = toolNames(tools); + for (const name of MARKER_TOOL_NAMES) { + if (present.has(name)) continue; + tools.push(markerTool(chat, name)); + changed = true; + } + if (changed || !Array.isArray(record.tools)) { + next.tools = tools; + changed = true; + } + + if (chat) { + const messages = next.messages as Array>; + const hasSystem = messages.some( + (message) => + message?.role === "system" && nonEmptyText(message.content) !== undefined, + ); + if (!hasSystem) { + next.messages = [{ role: "system", content: DEFAULT_SYSTEM_PROMPT }, ...messages]; + changed = true; + } + } else if (nonEmptyText(next.instructions) === undefined) { + next.instructions = DEFAULT_SYSTEM_PROMPT; + changed = true; + } + return { payload: next as T, changed }; +} + +export type FreeTierGateOutcome = + | { readonly status: "gated"; readonly body: string } + | { readonly status: "unchanged" } + | { readonly status: "incomplete" }; + +export function gateOpenCodeFreeBody(bodyText: string | undefined): FreeTierGateOutcome { + if (!bodyText || bodyText.length > GATE_BODY_MAX_CHARS) { + return { status: "incomplete" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return { status: "incomplete" }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { status: "incomplete" }; + } + const { payload, changed } = withOpenCodeFreeGate(parsed); + if (!hasMarkerTools(payload as Record)) { + return { status: "incomplete" }; + } + if (!changed) return { status: "unchanged" }; + return { status: "gated", body: JSON.stringify(payload) }; +} + +function readRequestUrl(input: Parameters[0]): string | undefined { + try { + if (input instanceof Request) return input.url; + if (input instanceof URL) return input.href; + return new URL(String(input)).href; + } catch { + return undefined; + } +} + +async function readInputBodyText( + input: Parameters[0], + init: Parameters[1], +): Promise { + try { + const body = init?.body; + if (typeof body === "string") return body; + if (body instanceof Uint8Array) { + return body.length > GATE_BODY_MAX_CHARS + ? undefined + : new TextDecoder().decode(body); + } + if (body instanceof ArrayBuffer) { + return body.byteLength > GATE_BODY_MAX_CHARS + ? undefined + : new TextDecoder().decode(body); + } + if (input instanceof Request && body === undefined) { + const text = await input.clone().text(); + return text.length > GATE_BODY_MAX_CHARS ? undefined : text; + } + } catch { + // Probe failures always pass through; gating must never block a request. + } + return undefined; +} + +/** + * A `fetch` wrapper that completes the free-tier signature on request bodies. + * Non-Zen hosts, non-`-free` models, and bodies the gate cannot complete pass + * through untouched (same URL, same init) so failures stay attributable to + * the gateway, not to a half-signed request. + */ +export function createOpenCodeFreeGateFetch( + fetchFn?: FetchFunction, +): FetchFunction { + const base = (fetchFn ?? globalThis.fetch.bind(globalThis)) as FetchFunction; + return (async (input, init) => { + const requestUrl = readRequestUrl(input); + if (requestUrl === undefined || !isOpenCodeZenRequest(requestUrl)) { + return base(input, init); + } + const bodyText = await readInputBodyText(input, init); + const probe = bodyText?.slice(0, MODEL_PROBE_MAX_CHARS) ?? ""; + const modelId = MODEL_FIELD_PATTERN.exec(probe)?.[1]; + if (!isOpenCodeFreeTierModelId(modelId)) { + return base(input, init); + } + const gated = gateOpenCodeFreeBody(bodyText); + if (gated.status !== "gated") { + return base(input, init); + } + return base(requestUrl, { + ...init, + method: init?.method ?? (input instanceof Request ? input.method : "POST"), + body: gated.body, + signal: init?.signal ?? (input instanceof Request ? input.signal : undefined), + }); + }) as FetchFunction; +} diff --git a/packages/agent-runtime/src/opencode-session-headers.test.ts b/packages/agent-runtime/src/opencode-session-headers.test.ts index 8430d80b0..fe9b9cfe7 100644 --- a/packages/agent-runtime/src/opencode-session-headers.test.ts +++ b/packages/agent-runtime/src/opencode-session-headers.test.ts @@ -6,15 +6,21 @@ import { type Model, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; -import { APP_VERSION } from "@pi-desktop/shared"; import { completeOneShot } from "./one-shot-complete.js"; import { OPENCODE_CLIENT_HEADER, OPENCODE_CLIENT_VALUE, + OPENCODE_CLI_VERSION, + OPENCODE_REQUEST_HEADER, OPENCODE_SESSION_HEADER, OPENCODE_USER_AGENT, + deriveOpenCodeSessionId, + isOpenCodeRequestId, + isOpenCodeSessionId, isOpenCodeEndpoint, mergeOpenCodeSessionHeaders, + newOpenCodeRequestId, + newOpenCodeSessionId, withOpenCodeSessionHeaders, } from "./opencode-session-headers.js"; import type { RuntimeProviderConfig } from "./provider-binding.js"; @@ -84,21 +90,26 @@ describe("isOpenCodeEndpoint", () => { }); describe("mergeOpenCodeSessionHeaders", () => { - it("injects session, client, and user-agent headers", () => { - expect( - mergeOpenCodeSessionHeaders({ - apiStyle: "opencode_go", - sessionId: "session-1", - }), - ).toEqual({ - [OPENCODE_SESSION_HEADER]: "session-1", - [OPENCODE_CLIENT_HEADER]: OPENCODE_CLIENT_VALUE, - "User-Agent": `pi-desktop/${APP_VERSION}`, + it("injects stamped request id with the native CLI identity", () => { + const headers = mergeOpenCodeSessionHeaders({ + apiStyle: "opencode_go", + sessionId: "session-1", + }); + // merge is verbatim: the timestamp-encoded wire session id is derived by + // withOpenCodeSessionHeaders (tested below). merge only adds what's missing. + expect(headers?.[OPENCODE_SESSION_HEADER]).toBe("session-1"); + expect(headers?.[OPENCODE_REQUEST_HEADER]).toMatch( + /^msg_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ); + expect(headers).toMatchObject({ + [OPENCODE_CLIENT_HEADER]: "cli", + "User-Agent": `opencode/${OPENCODE_CLI_VERSION}`, }); - expect(OPENCODE_USER_AGENT).toBe(`pi-desktop/${APP_VERSION}`); + expect(OPENCODE_CLIENT_VALUE).toBe("cli"); + expect(OPENCODE_USER_AGENT).toBe(`opencode/${OPENCODE_CLI_VERSION}`); }); - it("lets caller headers override client/UA but restores a missing session id", () => { + it("forces the native client identity but restores a missing session id", () => { expect( mergeOpenCodeSessionHeaders({ apiStyle: "opencode_go", @@ -106,17 +117,33 @@ describe("mergeOpenCodeSessionHeaders", () => { headers: { [OPENCODE_SESSION_HEADER]: null, [OPENCODE_CLIENT_HEADER]: "custom-client", + "User-Agent": "third-party/9.9", "X-Extra": "keep", }, }), ).toEqual({ [OPENCODE_SESSION_HEADER]: "session-1", - [OPENCODE_CLIENT_HEADER]: "custom-client", + [OPENCODE_REQUEST_HEADER]: expect.stringMatching( + /^msg_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ), + // A stale third-party client identity would fail the free-tier + // signature, so it is forced — unlike ordinary caller headers. + [OPENCODE_CLIENT_HEADER]: "cli", "User-Agent": OPENCODE_USER_AGENT, "X-Extra": "keep", }); }); + it("keeps a genuine opencode User-Agent suffix", () => { + expect( + mergeOpenCodeSessionHeaders({ + apiStyle: "opencode_go", + sessionId: "session-1", + headers: { "User-Agent": "opencode/1.18.32 VercelAI/5.0" }, + })?.["User-Agent"], + ).toBe("opencode/1.18.32 VercelAI/5.0"); + }); + it("preserves an explicit session header", () => { expect( mergeOpenCodeSessionHeaders({ @@ -149,7 +176,7 @@ describe("mergeOpenCodeSessionHeaders", () => { }); describe("withOpenCodeSessionHeaders", () => { - it("reuses options.sessionId and leaves retries with the same object fields", () => { + it("keeps the harness sessionId on the options and derives a stable wire id", () => { const options: SimpleStreamOptions = { temperature: 0 }; const first = withOpenCodeSessionHeaders(options, { apiStyle: "opencode_go", @@ -158,17 +185,30 @@ describe("withOpenCodeSessionHeaders", () => { const second = withOpenCodeSessionHeaders(first, { apiStyle: "opencode_go", }); + // Routing still uses the harness id; the wire header carries the derived + // timestamp-encoded id the gateway pins the conversation by. expect(first.sessionId).toBe("session-1"); expect(second.sessionId).toBe("session-1"); - expect(first.headers?.[OPENCODE_SESSION_HEADER]).toBe("session-1"); - expect(second.headers?.[OPENCODE_SESSION_HEADER]).toBe("session-1"); + expect(first.headers?.[OPENCODE_SESSION_HEADER]).toBe( + deriveOpenCodeSessionId("session-1"), + ); + expect(second.headers?.[OPENCODE_SESSION_HEADER]).toBe( + first.headers?.[OPENCODE_SESSION_HEADER], + ); + // A retry reuses the same options object, so the request id it already + // carries is preserved and the retry stays byte-identical. + expect(first.headers?.[OPENCODE_REQUEST_HEADER]).toMatch( + /^msg_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ); + expect(second.headers?.[OPENCODE_REQUEST_HEADER]).toBe( + first.headers?.[OPENCODE_REQUEST_HEADER], + ); expect(first.temperature).toBe(0); }); - it("synthesizes a session id for OpenCode one-shot calls that have none", () => { + it("synthesizes a stamped session id for OpenCode one-shot calls that have none", () => { const result = withOpenCodeSessionHeaders({}, { apiStyle: "opencode_go" }); - expect(result.sessionId).toEqual(expect.any(String)); - expect(result.sessionId?.length).toBeGreaterThan(8); + expect(result.sessionId).toMatch(/^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$/); expect(result.headers?.[OPENCODE_SESSION_HEADER]).toBe(result.sessionId); }); @@ -243,10 +283,15 @@ describe("completeOneShot OpenCode headers", () => { expect(result.text).toBe("ok"); expect(result.usage).toMatchObject({ operationId: expect.any(String), providerId: provider.id, modelId: provider.modelId }); expect(captured?.sessionId).toBe("session-9"); + expect(captured?.headers?.[OPENCODE_SESSION_HEADER]).toBe( + deriveOpenCodeSessionId("session-9"), + ); expect(captured?.headers).toMatchObject({ - [OPENCODE_SESSION_HEADER]: "session-9", - [OPENCODE_CLIENT_HEADER]: OPENCODE_CLIENT_VALUE, + [OPENCODE_CLIENT_HEADER]: "cli", "User-Agent": OPENCODE_USER_AGENT, + [OPENCODE_REQUEST_HEADER]: expect.stringMatching( + /^msg_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ), }); }); @@ -359,6 +404,37 @@ describe("completeOneShot OpenCode headers", () => { }); }); +describe("stamped OpenCode ids", () => { + it("mints ses_/msg_ ids in the native shape", () => { + expect(newOpenCodeSessionId()).toMatch(/^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$/); + expect(newOpenCodeRequestId()).toMatch(/^msg_[0-9a-f]{12}[A-Za-z0-9]{14}$/); + expect(isOpenCodeSessionId(newOpenCodeSessionId())).toBe(true); + expect(isOpenCodeRequestId(newOpenCodeRequestId())).toBe(true); + }); + + it("rejects UUID, random, and cross-prefix ids", () => { + expect(isOpenCodeSessionId("123e4567-e89b-12d3-a456-426614174000")).toBe(false); + expect(isOpenCodeSessionId("ses_" + "x".repeat(26))).toBe(false); + expect(isOpenCodeSessionId(newOpenCodeRequestId())).toBe(false); + expect(isOpenCodeRequestId(newOpenCodeSessionId())).toBe(false); + expect(isOpenCodeSessionId(" ")).toBe(false); + }); + + it("derives one stable id per harness conversation", () => { + const first = deriveOpenCodeSessionId("conv-7"); + expect(first).toMatch(/^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$/); + expect(deriveOpenCodeSessionId("conv-7")).toBe(first); + expect(deriveOpenCodeSessionId("conv-8")).not.toBe(first); + }); + + it("mints a fresh id for ad-hoc calls without a conversation", () => { + expect(deriveOpenCodeSessionId(" ")).toMatch( + /^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ); + expect(deriveOpenCodeSessionId("")).not.toBe(deriveOpenCodeSessionId("")); + }); +}); + describe("OpenCode header call-site wiring", () => { it("is applied on session, subagent, and one-shot streams", () => { const sources = [ diff --git a/packages/agent-runtime/src/opencode-session-headers.ts b/packages/agent-runtime/src/opencode-session-headers.ts index c3bbb81c4..5101f5aaf 100644 --- a/packages/agent-runtime/src/opencode-session-headers.ts +++ b/packages/agent-runtime/src/opencode-session-headers.ts @@ -3,20 +3,126 @@ * gateway can pin a chat to one backend. pi-ai does not emit * `x-opencode-session`; the official Pi coding-agent injects it in the agent * layer, and this runtime does the same. + * + * Free-tier requests must be byte-equivalent to the native opencode CLI or the + * gateway answers `403 FreeTierError`: timestamp-encoded `ses_`/`msg_` ids + * (random/UUID ids are rejected even when everything else is perfect), + * `x-opencode-client: cli` and `User-Agent: opencode/`. */ -import { randomUUID } from "node:crypto"; import type { Api, Model, ProviderHeaders, SimpleStreamOptions } from "@earendil-works/pi-ai"; -import { - APP_VERSION, - OPENCODE_GO_API_STYLE, -} from "@pi-desktop/shared"; +import { OPENCODE_GO_API_STYLE } from "@pi-desktop/shared"; +import { createOpenCodeFreeGateFetch } from "./opencode-free-gate.js"; import type { RuntimeProviderConfig } from "./provider-binding.js"; export const OPENCODE_SESSION_HEADER = "x-opencode-session"; +export const OPENCODE_REQUEST_HEADER = "x-opencode-request"; export const OPENCODE_CLIENT_HEADER = "x-opencode-client"; -export const OPENCODE_CLIENT_VALUE = "pi-desktop"; -export const OPENCODE_USER_AGENT = `pi-desktop/${APP_VERSION}`; +export const OPENCODE_CLIENT_VALUE = "cli"; +// Native client fingerprint the gateway validates ("1.18.0 or newer is +// required"). Bump alongside real opencode releases: +// https://github.com/sst/opencode/releases +export const OPENCODE_CLI_VERSION = "1.18.32"; +export const OPENCODE_USER_AGENT = `opencode/${OPENCODE_CLI_VERSION}`; + +const SESSION_ID_PREFIX = "ses_"; +const REQUEST_ID_PREFIX = "msg_"; +const TIMESTAMP_HEX_LENGTH = 12; +const RANDOM_TAIL_LENGTH = 14; +const TIME_BYTES = 6; + +const BASE62_CHARS = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +let lastIdTimestamp = 0; +let idCounter = 0; + +/** + * Exact port of opencode `identifier.ts` descending(): `~(ms * 0x1000 + + * per-ms counter)`, low 48 bits as lowercase hex. BigInt keeps the bitwise-NOT + * semantics for large timestamps. Same-era ids share high bytes — if yours + * don't, the port is wrong. + */ +export function descendingTimestampHex(now: number = Date.now()): string { + const timestamp = Math.trunc(now); + if (timestamp !== lastIdTimestamp) { + lastIdTimestamp = timestamp; + idCounter = 0; + } + idCounter += 1; + const value = ~(BigInt(timestamp) * 0x1000n + BigInt(idCounter)); + let time = ""; + for (let index = 0; index < TIME_BYTES; index += 1) { + time += Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0"); + } + return time; +} + +function randomBase62(length: number): string { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + let result = ""; + for (const byte of bytes) { + result += BASE62_CHARS[byte % BASE62_CHARS.length]; + } + return result; +} + +/** Fresh timestamp-encoded conversation id (`ses_` + 26 chars). */ +export function newOpenCodeSessionId(now: number = Date.now()): string { + return `${SESSION_ID_PREFIX}${descendingTimestampHex(now)}${randomBase62(RANDOM_TAIL_LENGTH)}`; +} + +/** Fresh timestamp-encoded per-request id (`msg_` + 26 chars). */ +export function newOpenCodeRequestId(now: number = Date.now()): string { + return `${REQUEST_ID_PREFIX}${descendingTimestampHex(now)}${randomBase62(RANDOM_TAIL_LENGTH)}`; +} + +function isStampedId(value: unknown, prefix: string): boolean { + if (typeof value !== "string") return false; + if ( + value.length !== + prefix.length + TIMESTAMP_HEX_LENGTH + RANDOM_TAIL_LENGTH + ) { + return false; + } + if (!value.startsWith(prefix)) return false; + return /^[0-9a-f]{12}[A-Za-z0-9]{14}$/.test(value.slice(prefix.length)); +} + +export function isOpenCodeSessionId(value: unknown): boolean { + return isStampedId(value, SESSION_ID_PREFIX); +} + +export function isOpenCodeRequestId(value: unknown): boolean { + return isStampedId(value, REQUEST_ID_PREFIX); +} + +const MAX_CACHED_SESSIONS = 500; +const sessionCache = new Map(); + +/** + * Stable wire session id for one harness conversation. The seed (the harness + * session id) only selects the cache slot — the content is always a fresh + * timestamp-encoded id generated on first sight, because the gateway pins a + * conversation to one backend by this id. An empty seed (ad-hoc calls with no + * conversation context) gets a fresh id per call. + */ +export function deriveOpenCodeSessionId(seed: string): string { + const key = seed.trim(); + if (!key) return newOpenCodeSessionId(Date.now()); + let id = sessionCache.get(key); + if (!id) { + if (sessionCache.size >= MAX_CACHED_SESSIONS) { + sessionCache.clear(); + } + id = newOpenCodeSessionId(Date.now()); + sessionCache.set(key, id); + } + return id; +} export type OpenCodeEndpointInput = { apiStyle?: string; @@ -73,11 +179,14 @@ export function openCodeEndpointFromProvider( }; } -/** Merge OpenCode routing headers. An explicit caller header wins, except an - * empty/null `x-opencode-session` is replaced so the gateway cannot 400. */ +/** Merge OpenCode routing headers. An explicit caller session/request header + * wins, except an empty/null value is replaced so the gateway cannot 400. The + * client identity is always forced to the native CLI fingerprint — a stale + * third-party value would fail the free-tier signature with 403. */ export function mergeOpenCodeSessionHeaders( input: OpenCodeEndpointInput & { sessionId?: string; + requestId?: string; headers?: ProviderHeaders; }, ): ProviderHeaders | undefined { @@ -85,9 +194,11 @@ export function mergeOpenCodeSessionHeaders( if (!sessionId || !isOpenCodeEndpoint(input)) { return input.headers; } + const requestId = input.requestId?.trim() || newOpenCodeRequestId(); const injected: ProviderHeaders = { [OPENCODE_SESSION_HEADER]: sessionId, + [OPENCODE_REQUEST_HEADER]: requestId, [OPENCODE_CLIENT_HEADER]: OPENCODE_CLIENT_VALUE, "User-Agent": OPENCODE_USER_AGENT, }; @@ -98,34 +209,54 @@ export function mergeOpenCodeSessionHeaders( if (!headerValue(merged, OPENCODE_SESSION_HEADER)) { merged[OPENCODE_SESSION_HEADER] = sessionId; } - if (!headerValue(merged, OPENCODE_CLIENT_HEADER)) { - merged[OPENCODE_CLIENT_HEADER] = OPENCODE_CLIENT_VALUE; + if (!headerValue(merged, OPENCODE_REQUEST_HEADER)) { + merged[OPENCODE_REQUEST_HEADER] = requestId; } - if (!headerValue(merged, "user-agent")) { + merged[OPENCODE_CLIENT_HEADER] = OPENCODE_CLIENT_VALUE; + const userAgent = headerValue(merged, "user-agent"); + if (!userAgent || !userAgent.toLowerCase().startsWith("opencode/")) { merged["User-Agent"] = OPENCODE_USER_AGENT; } return merged; } /** Attach OpenCode routing headers to a pi-ai stream options object. - * OpenCode requests without a caller session id get a per-call UUID so the - * gateway still accepts the request; retries reuse the same options object. */ + * The harness session id stays on the options for request routing; the wire + * header carries a stable timestamp-encoded id derived from it (the gateway + * pins a conversation to one backend by this id and rejects UUID/random ids). + * OpenCode requests without a caller session id get a fresh stamped id per + * call; retries reuse the same options object, so the request id they already + * carry is preserved and the retry stays byte-identical. */ export function withOpenCodeSessionHeaders( options: SimpleStreamOptions | undefined, input: OpenCodeEndpointInput & { sessionId?: string }, ): SimpleStreamOptions { const preferred = (options?.sessionId ?? input.sessionId)?.trim() || undefined; + const onOpenCode = isOpenCodeEndpoint(input); const sessionId = - preferred ?? (isOpenCodeEndpoint(input) ? randomUUID() : undefined); + preferred ?? (onOpenCode ? newOpenCodeSessionId() : undefined); + const wireSessionId = + onOpenCode && sessionId + ? (preferred ? deriveOpenCodeSessionId(preferred) : sessionId) + : sessionId; const headers = mergeOpenCodeSessionHeaders({ ...input, - sessionId, + sessionId: wireSessionId, headers: options?.headers, }); + // The body gate rides the request's fetch so every call site already wrapped + // here (session turns, subagents, one-shot, compaction) shares one hook. + const gateFetch = + onOpenCode && sessionId + ? createOpenCodeFreeGateFetch(options?.fetch) + : options?.fetch; return { ...(options ?? {}), ...(sessionId ? { sessionId } : {}), ...(headers ? { headers } : {}), + ...(gateFetch !== undefined && gateFetch !== options?.fetch + ? { fetch: gateFetch } + : {}), }; } diff --git a/packages/agent-runtime/src/provider-headers.test.ts b/packages/agent-runtime/src/provider-headers.test.ts index 1a73490d6..57395389a 100644 --- a/packages/agent-runtime/src/provider-headers.test.ts +++ b/packages/agent-runtime/src/provider-headers.test.ts @@ -29,6 +29,9 @@ describe("normalizeProviderHeaders", () => { expect( normalizeProviderHeaders({ "x-opencode-session": "hijack" }), ).toBeUndefined(); + expect( + normalizeProviderHeaders({ "x-opencode-request": "hijack" }), + ).toBeUndefined(); expect(normalizeProviderHeaders({ "X_Nope": "1" })).toBeUndefined(); }); diff --git a/packages/agent-runtime/src/provider-headers.ts b/packages/agent-runtime/src/provider-headers.ts index 1de2d41f0..f848b5f8b 100644 --- a/packages/agent-runtime/src/provider-headers.ts +++ b/packages/agent-runtime/src/provider-headers.ts @@ -48,6 +48,7 @@ const FORBIDDEN_HEADER_KEYS = new Set([ "api-key", "chatgpt-account-id", "x-opencode-session", + "x-opencode-request", ]); const oauthHeaders = new AsyncLocalStorage>(); diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index c62804ac0..2c54592d0 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -8921,9 +8921,11 @@ describe("DesktopAgentRuntime compaction request headers", () => { expect(result.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0]?.sessionId).toBe("session-1"); + expect(calls[0]?.headers?.["x-opencode-session"]).toMatch( + /^ses_[0-9a-f]{12}[A-Za-z0-9]{14}$/, + ); expect(calls[0]?.headers).toMatchObject({ - "x-opencode-session": "session-1", - "x-opencode-client": "pi-desktop", + "x-opencode-client": "cli", "X-Team": "platform", }); await runtime.dispose();