diff --git a/docs/runbooks/skill-harness-proof.md b/docs/runbooks/skill-harness-proof.md index c3477267..34461295 100644 --- a/docs/runbooks/skill-harness-proof.md +++ b/docs/runbooks/skill-harness-proof.md @@ -73,6 +73,7 @@ The outbound gateway allows HTTPS npm retrieval, GitHub source archives, and the It blocks other destinations, hosted model tools, credential overrides, and model conversation reuse. For OpenCode Go, only chat completions pass. Every tool must have the `function` type. The gateway removes extra choices and service tier requests, because both raise the price. +It forwards the `x-opencode-*` session headers, because OpenCode Go rejects a request without `x-opencode-session`. External documentation requests are blocked. Source files must carry the evidence for this proof. If generation fails, read `state.result.detail` and `state.result.generation` in the saved result. diff --git a/workers/skill-harness/src/gateway.ts b/workers/skill-harness/src/gateway.ts index 61e29e74..aca4e623 100644 --- a/workers/skill-harness/src/gateway.ts +++ b/workers/skill-harness/src/gateway.ts @@ -3,6 +3,23 @@ import { MAX_REQUEST_BYTES, parseJson, readBoundedBody } from './contracts' export type ModelProvider = 'google' | 'anthropic' | 'opencode-go' const OPENCODE_GO_CHAT_URL = 'https://opencode.ai/zen/go/v1/chat/completions' + +/** + * OpenCode Go routes and caches by these headers, and answers 400 MissingSessionID + * without `x-opencode-session`. They name the session; none carries a credential. + */ +const OPENCODE_ROUTING_HEADERS = ['x-opencode-client', 'x-opencode-project', 'x-opencode-request', 'x-opencode-session'] as const +const ROUTING_VALUE = /^[\w.:-]{1,200}$/ + +function openCodeRoutingHeaders(request: Request): Record { + const headers: Record = {} + for (const name of OPENCODE_ROUTING_HEADERS) { + const value = request.headers.get(name) + if (value !== null && ROUTING_VALUE.test(value)) + headers[name] = value + } + return headers +} const OPENCODE_GO_MAX_OUTPUT_TOKENS = 8192 // Web search, hosted plugins, live search, and remote MCP run on the provider at a separate cost. const OPENAI_COMPATIBLE_HOSTED_FIELDS = ['web_search_options', 'plugins', 'search_parameters', 'mcp_servers'] @@ -102,7 +119,7 @@ export async function forwardSandboxRequest( delete standardInput.service_tier return fetchClient(OPENCODE_GO_CHAT_URL, { method: 'POST', - headers: { 'content-type': 'application/json', 'authorization': `Bearer ${options.apiKey}` }, + headers: { 'content-type': 'application/json', 'authorization': `Bearer ${options.apiKey}`, ...openCodeRoutingHeaders(request) }, body: JSON.stringify({ ...standardInput, model: options.model, diff --git a/workers/skill-harness/test/gateway.test.ts b/workers/skill-harness/test/gateway.test.ts index 5b35eb6f..a5491f54 100644 --- a/workers/skill-harness/test/gateway.test.ts +++ b/workers/skill-harness/test/gateway.test.ts @@ -23,10 +23,13 @@ function openCodeGo() { return { ...fixture(), provider: 'opencode-go' as const, model: 'glm-5.3' } } -function openCodeGoRequest(body: unknown, url = 'https://opencode.ai/zen/go/v1/chat/completions?key=untrusted') { +/** OpenCode sends these routing headers. OpenCode Go rejects a request without `x-opencode-session`. */ +const openCodeRouting = { 'x-opencode-client': 'cli', 'x-opencode-project': 'global', 'x-opencode-request': 'msg_01K7Q3', 'x-opencode-session': 'ses_6f1c2b9a' } + +function openCodeGoRequest(body: unknown, url = 'https://opencode.ai/zen/go/v1/chat/completions?key=untrusted', routing: Record = openCodeRouting) { return new Request(url, { method: 'POST', - headers: { 'authorization': 'Bearer sandbox-placeholder', 'content-type': 'application/json', 'x-opencode-session': 'untrusted', 'cookie': 'private=cookie' }, + headers: { 'authorization': 'Bearer sandbox-placeholder', 'content-type': 'application/json', 'cookie': 'private=cookie', ...routing }, body: typeof body === 'string' ? body : JSON.stringify(body), }) } @@ -48,7 +51,11 @@ describe('sandbox credential gateway', () => { const [url, forwarded] = options.fetch.mock.calls[0]! expect(String(url)).toBe('https://opencode.ai/zen/go/v1/chat/completions') expect(forwarded?.redirect).toBe('manual') - expect([...new Headers(forwarded?.headers).entries()]).toEqual([['authorization', 'Bearer worker-only-secret'], ['content-type', 'application/json']]) + expect([...new Headers(forwarded?.headers).entries()]).toEqual([ + ['authorization', 'Bearer worker-only-secret'], + ['content-type', 'application/json'], + ...Object.entries(openCodeRouting), + ]) const body = JSON.parse(String(forwarded?.body)) expect(body).toMatchObject({ model: 'glm-5.3', max_tokens: 8192, max_completion_tokens: 8192, stream: true, tools: [{ type: 'function' }] }) expect(body).not.toHaveProperty('n') @@ -56,6 +63,16 @@ describe('sandbox credential gateway', () => { expect(options.consumeModelCall).toHaveBeenCalledOnce() }) + it('drops an OpenCode routing header whose value is not a plain identifier', async () => { + const options = openCodeGo() + await forwardSandboxRequest(openCodeGoRequest({ messages: [] }, undefined, { 'x-opencode-session': 'ses_ok', 'x-opencode-request': 'a b', 'x-opencode-project': 'p'.repeat(300) }), options) + const [, forwarded] = options.fetch.mock.calls[0]! + const headers = new Headers(forwarded?.headers) + expect(headers.get('x-opencode-session')).toBe('ses_ok') + expect(headers.has('x-opencode-request')).toBe(false) + expect(headers.has('x-opencode-project')).toBe(false) + }) + it.each([ [{ max_tokens: 1000 }, { max_tokens: 1000 }], [{}, { max_tokens: 8192 }],