diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index a651fe6186..d1fd43ff8c 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -442,15 +442,22 @@ describe('opencode external provider config', () => { await writeFile(keyFile, 'upstream-secret'); await chmod(keyFile, 0o600); let observed = null; + let observedBody = null; + const requestBytes = Buffer.from('{ "model": "fake", "messages": [{ "role": "assistant", "thought_signature": "σ雪" }] }'); + const responseBytes = Buffer.from('{ "ok": true, "thought_signature": "σ雪" }\n'); const upstream = createServer((req, res) => { observed = { authorization: req.headers.authorization, host: req.headers.host, url: req.url, }; - req.resume(); - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + observedBody = Buffer.concat(chunks); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(responseBytes); + }); }); await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); const upstreamPort = upstream.address().port; @@ -475,10 +482,11 @@ describe('opencode external provider config', () => { const response = await fetch(`${proxy.baseURL}/chat/completions`, { method: 'POST', headers: { authorization: `Bearer ${proxyToken}`, 'content-type': 'application/json' }, - body: '{"model":"fake"}', + body: requestBytes, }); expect(response.status).toBe(200); - expect(await response.text()).toBe('{"ok":true}'); + expect(observedBody).toEqual(requestBytes); + expect(Buffer.from(await response.arrayBuffer())).toEqual(responseBytes); expect(observed).toEqual({ authorization: 'Bearer upstream-secret', host: `127.0.0.1:${upstreamPort}`, @@ -737,11 +745,9 @@ describe('opencode external provider config', () => { }); }); - // TASK-251: Gemini 3 needs its thought signatures back on the next request - // and OpenCode's provider drops them, so a Gemini seat goes through the - // signing proxy even when private. The test above is the control: any other - // private provider still goes direct. - test('a private Gemini seat goes through the loopback proxy, never direct', async () => { + // OpenCode 1.18.35 and 1.19.0 replay Gemini thought signatures natively. + // Private seats can use the same direct provider path as other private seats. + test('a private Gemini seat uses its provider directly', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); const keyFile = join(root, 'secrets', 'gemini-key'); @@ -753,8 +759,8 @@ describe('opencode external provider config', () => { onCall: (_cmd, _args, options) => { const config = JSON.parse(readFileSync(options.env.OPENCODE_CONFIG, 'utf8')); const { baseURL, apiKey } = config.provider.google.options; - expect(baseURL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1beta\/openai$/); - expect(apiKey).not.toBe(`{file:${realpathSync(keyFile)}}`); + expect(baseURL).toBe('https://generativelanguage.googleapis.com/v1beta/openai'); + expect(apiKey).toBe(`{file:${realpathSync(keyFile)}}`); }, }); await opencode.spawn('gemini turn', { @@ -766,7 +772,6 @@ describe('opencode external provider config', () => { id: 'google', baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', keyFile, }, }, - _geminiSignatureDir: join(root, 'signatures'), _binaryPath: '/usr/local/bin/opencode', _spawnImpl: spawn.impl, }); @@ -774,6 +779,38 @@ describe('opencode external provider config', () => { }); }); + test.each([ + ['older', { status: 0, stdout: 'OpenCode 1.18.0\n' }, '1.18.0'], + ['boundary prerelease', { status: 0, stdout: 'OpenCode 1.18.35-rc.1\n' }, '1.18.35-rc.1'], + ['later patch prerelease', { status: 0, stdout: 'OpenCode 1.18.36-rc.1\n' }, '1.18.36-rc.1'], + ['later minor prerelease', { status: 0, stdout: 'OpenCode 1.19.0-rc.1\n' }, '1.19.0-rc.1'], + ['unknown', { status: 1, stdout: '' }, 'unknown'], + ])('a private Gemini seat refuses %s OpenCode before spawning', async (_label, result, version) => { + await withTemp(async (root) => { + const workspace = join(root, 'workspace'); + const keyFile = join(root, 'secrets', 'gemini-key'); + await mkdir(workspace, { recursive: true }); + await mkdir(join(root, 'secrets'), { recursive: true }); + await writeFile(keyFile, 'gemini-test-key'); + await chmod(keyFile, 0o600); + spawnSyncMock.mockReturnValue(result); + const spawn = makeSpawnImpl(); + await expect(opencode.spawn('gemini turn', { + cwd: workspace, + env: { PATH: process.env.PATH }, + environment: { + model: 'gemini-3-flash-preview', + provider: { + id: 'google', baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', keyFile, + }, + }, + _binaryPath: '/usr/local/bin/opencode', + _spawnImpl: spawn.impl, + })).rejects.toThrow(`Gemini requires stable OpenCode 1.18.35 or later for native thought-signature replay; installed version is ${version}`); + expect(spawn.calls).toHaveLength(0); + }); + }); + test('refuses provider keys with unsafe paths before spawning', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); diff --git a/cli/__tests__/gemini-signatures.test.mjs b/cli/__tests__/gemini-signatures.test.mjs deleted file mode 100644 index fc9a35bcd8..0000000000 --- a/cli/__tests__/gemini-signatures.test.mjs +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Gemini's thought signatures round-trip through the provider proxy - * (TASK-251; approach from magpie, MIT). Gemini 3 refuses the next request of a - * tool loop without the signature back on the call, and OpenCode's provider - * drops the field, so the proxy carries a reference in the call's id. - */ -import { createServer, request as httpRequest } from 'http'; -import { - chmod, mkdtemp, readdir, rm, stat, utimes, writeFile, -} from 'fs/promises'; -import { readFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { - SKIP_SIGNATURE, - createReplySigner, - createSignatureStore, - isGeminiOpenAIUpstream, - restoreRequestSignatures, - signedId, - unsignedId, -} from '../src/lib/gemini-signatures.js'; -import { startProviderProxy } from '../src/lib/provider-proxy.js'; - -const SIG = 'CiQB4dpSignatureBytes+/=='; -const KEY = 'AAAAAAAAAAAAAAAA'; - -let root; -beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'cli-gemini-sig-')); }); -afterEach(async () => { await rm(root, { recursive: true, force: true }); }); - -describe('the reference in a call id', () => { - test('round-trips, and leaves a plain id as it is', () => { - expect(unsignedId(signedId('call_1', KEY))).toEqual({ id: 'call_1', key: KEY }); - expect(unsignedId('call_1')).toEqual({ id: 'call_1', key: null }); - }); - - test('an id a client shortened is no signature, not some other key', () => { - const cut = signedId('call_1', KEY).slice(0, -3); - expect(unsignedId(cut)).toEqual({ id: cut, key: null }); - }); - - test('a mark whose stated length is wrong, or whose key is not 16 long, is no signature', () => { - expect(unsignedId('call_1__cmts15__AAAAAAAAAAAAAAAA').key).toBeNull(); - expect(unsignedId('call_1__cmts3__abc').key).toBeNull(); - }); - - test('only Gemini\'s OpenAI-compatible host turns it on', () => { - expect(isGeminiOpenAIUpstream('https://generativelanguage.googleapis.com/v1beta/openai')).toBe(true); - expect(isGeminiOpenAIUpstream('https://api.deepseek.com/v1')).toBe(false); - expect(isGeminiOpenAIUpstream('not a url')).toBe(false); - }); -}); - -describe('the signature store', () => { - test('a later spawn finds what an earlier one kept, in 0600 files under a 0700 dir', async () => { - const dir = join(root, 'sigs'); - const key = await createSignatureStore(dir).keep(SIG); - expect(await createSignatureStore(dir).lookup(key)).toBe(SIG); - // eslint-disable-next-line no-bitwise - expect((await stat(dir)).mode & 0o777).toBe(0o700); - // eslint-disable-next-line no-bitwise - expect((await stat(join(dir, key))).mode & 0o777).toBe(0o600); - }); - - test('a file that does not hash to its name is no signature', async () => { - const dir = join(root, 'sigs'); - const key = await createSignatureStore(dir).keep(SIG); - await writeFile(join(dir, key), 'something else'); - expect(await createSignatureStore(dir).lookup(key)).toBeNull(); - }); - - test('without a dir it is this spawn only', async () => { - const key = await createSignatureStore().keep(SIG); - expect(await createSignatureStore().lookup(key)).toBeNull(); - }); - - test('prune removes signatures not read for 30 days and old staged files', async () => { - const dir = join(root, 'sigs'); - const store = createSignatureStore(dir); - const fresh = await store.keep('fresh'); - const old = await store.keep('old'); - await writeFile(join(dir, 'x.tmp'), ''); - const longAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); - await utimes(join(dir, old), longAgo, longAgo); - await utimes(join(dir, 'x.tmp'), longAgo, longAgo); - await store.prune(); - expect((await readdir(dir)).sort()).toEqual([fresh]); - }); -}); - -describe('restoring signatures on a request', () => { - const body = (messages) => Buffer.from(JSON.stringify({ model: 'gemini-3', messages })); - const parsed = (buffer) => JSON.parse(buffer.toString('utf8')).messages; - - test('a referenced call gets its own id back and its signature in extra_content; its result answers that id', async () => { - const store = createSignatureStore(); - const key = await store.keep(SIG); - const out = parsed(await restoreRequestSignatures(body([ - { role: 'assistant', tool_calls: [{ id: signedId('call_1', key), type: 'function', function: { name: 'f', arguments: '{}' } }] }, - { role: 'tool', tool_call_id: signedId('call_1', key), content: 'ok' }, - ]), store)); - expect(out[0].tool_calls[0]).toMatchObject({ id: 'call_1', extra_content: { google: { thought_signature: SIG } } }); - expect(out[1].tool_call_id).toBe('call_1'); - }); - - test('in a signed step only the signed (first) call carries it; a step with none gets the skip value on every call', async () => { - const store = createSignatureStore(); - const key = await store.keep(SIG); - const out = parsed(await restoreRequestSignatures(body([ - { role: 'assistant', tool_calls: [{ id: signedId('a', key) }, { id: 'b' }] }, - { role: 'assistant', tool_calls: [{ id: 'c' }, { id: 'd' }] }, - ]), store)); - expect(out[0].tool_calls[0].extra_content.google.thought_signature).toBe(SIG); - expect(out[0].tool_calls[1].extra_content).toBeUndefined(); - expect(out[1].tool_calls.map((call) => call.extra_content.google.thought_signature)).toEqual([SKIP_SIGNATURE, SKIP_SIGNATURE]); - }); - - test('a reference the store no longer has is unsigned history, not a failure', async () => { - const out = parsed(await restoreRequestSignatures(body([ - { role: 'assistant', tool_calls: [{ id: signedId('call_1', KEY) }] }, - ]), createSignatureStore())); - expect(out[0].tool_calls[0]).toMatchObject({ id: 'call_1', extra_content: { google: { thought_signature: SKIP_SIGNATURE } } }); - }); - - test('a body that is not JSON, or needs nothing, is sent as it came', async () => { - const store = createSignatureStore(); - const notJson = Buffer.from('not json'); - expect(await restoreRequestSignatures(notJson, store)).toBe(notJson); - const plain = body([{ role: 'user', content: 'hi' }]); - expect(await restoreRequestSignatures(plain, store)).toBe(plain); - }); -}); - -describe('signing a reply', () => { - const chunk = (delta) => `data: ${JSON.stringify({ choices: [{ index: 0, delta }] })}\n\n`; - - test('a streamed call with a signature gets a referencing id; every other line passes byte for byte', async () => { - const store = createSignatureStore(); - const signer = createReplySigner({ store, sse: true }); - const text = chunk({ content: 'héllo' }) - + chunk({ tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'f', arguments: '{}' }, extra_content: { google: { thought_signature: SIG } } }] }) - + 'data: [DONE]\n\n'; - const bytes = Buffer.from(text, 'utf8'); - // Split inside "é" and inside the signed line, the way a socket might. - const cutA = bytes.indexOf(Buffer.from('é')) + 1; - const cutB = bytes.indexOf(Buffer.from('thought_signature')) + 5; - const out = (await signer.write(bytes.subarray(0, cutA))) - + (await signer.write(bytes.subarray(cutA, cutB))) - + (await signer.write(bytes.subarray(cutB))) - + (await signer.end()); - const lines = out.split('\n'); - expect(lines[0]).toBe(chunk({ content: 'héllo' }).split('\n')[0]); - const call = JSON.parse(lines[2].slice(6)).choices[0].delta.tool_calls[0]; - const { id, key } = unsignedId(call.id); - expect(id).toBe('call_1'); - expect(await store.lookup(key)).toBe(SIG); - expect(out.endsWith('data: [DONE]\n\n')).toBe(true); - }); - - test('a whole JSON reply is signed at the end', async () => { - const store = createSignatureStore(); - const signer = createReplySigner({ store, sse: false }); - const reply = JSON.stringify({ choices: [{ message: { tool_calls: [{ id: 'call_9', extra_content: { google: { thought_signature: SIG } } }] } }] }); - expect(await signer.write(Buffer.from(reply))).toBe(''); - const call = JSON.parse(await signer.end()).choices[0].message.tool_calls[0]; - const { id, key } = unsignedId(call.id); - expect(id).toBe('call_9'); - expect(key).toMatch(/^[A-Za-z0-9_-]{16}$/); - expect(await store.lookup(key)).toBe(SIG); - }); - - test('a reply with no signature is unchanged', async () => { - const signer = createReplySigner({ store: createSignatureStore(), sse: false }); - const reply = '{"choices":[{"message":{"content":"ok"}}]}'; - await signer.write(Buffer.from(reply)); - expect(await signer.end()).toBe(reply); - }); -}); - -describe('through the provider proxy', () => { - const startUpstream = async (handler) => { - const seen = []; - const server = createServer((req, res) => { - const parts = []; - req.on('data', (c) => parts.push(c)); - req.on('end', () => { - seen.push({ headers: req.headers, body: Buffer.concat(parts).toString('utf8') }); - handler(seen.length, res); - }); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - return { server, seen, port: server.address().port }; - }; - - const startProxy = async (port, signatureStore) => { - const keyFile = join(root, 'key'); - await writeFile(keyFile, 'upstream-secret'); - await chmod(keyFile, 0o600); - const tokenFile = join(root, 'token'); - const proxy = await startProviderProxy({ - provider: { baseURL: `http://127.0.0.1:${port}/v1beta/openai` }, keyFile, tokenFile, signatureStore, - }); - return { proxy, token: readFileSync(tokenFile, 'utf8') }; - }; - - const post = (proxy, token, body) => fetch(`${proxy.baseURL}/chat/completions`, { - method: 'POST', - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); - - test('the signature from a streamed reply is back on the next request, with the call\'s own id', async () => { - const up = await startUpstream((n, res) => { - if (n === 1) { - res.writeHead(200, { 'content-type': 'text/event-stream' }); - res.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'fc_1', type: 'function', function: { name: 'commonly_get_context', arguments: '{}' }, extra_content: { google: { thought_signature: SIG } } }] } }] })}\n\n`); - res.end('data: [DONE]\n\n'); - return; - } - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"choices":[{"message":{"content":"done"}}]}'); - }); - const { proxy, token } = await startProxy(up.port, createSignatureStore(join(root, 'sigs'))); - try { - const first = await (await post(proxy, token, { model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }] })).text(); - const call = JSON.parse(first.split('\n')[0].slice(6)).choices[0].delta.tool_calls[0]; - expect(call.id).not.toBe('fc_1'); - // What OpenCode sends back: the id it was given, and no extra_content. - const second = await post(proxy, token, { - model: 'm', - messages: [ - { role: 'user', content: 'hi' }, - { role: 'assistant', tool_calls: [{ id: call.id, type: 'function', function: call.function }] }, - { role: 'tool', tool_call_id: call.id, content: '{}' }, - ], - }); - expect(await second.text()).toBe('{"choices":[{"message":{"content":"done"}}]}'); - const sent = JSON.parse(up.seen[1].body).messages; - expect(sent[1].tool_calls[0]).toMatchObject({ id: 'fc_1', extra_content: { google: { thought_signature: SIG } } }); - expect(sent[2].tool_call_id).toBe('fc_1'); - expect(up.seen[1].headers['content-length']).toBe(String(Buffer.byteLength(up.seen[1].body))); - expect(up.seen.every((r) => r.headers['accept-encoding'] === 'identity')).toBe(true); - } finally { - await proxy.close(); - await new Promise((resolve) => up.server.close(resolve)); - } - }); - - test('without a store, requests and replies pass unchanged, as for any other provider', async () => { - const reply = `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ id: 'fc_1', extra_content: { google: { thought_signature: SIG } } }] } }] })}\n\n`; - const up = await startUpstream((n, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }); - res.end(reply); - }); - const { proxy, token } = await startProxy(up.port, null); - try { - expect(await (await post(proxy, token, { messages: [{ role: 'assistant', tool_calls: [{ id: signedId('x', KEY) }] }] })).text()).toBe(reply); - expect(JSON.parse(up.seen[0].body).messages[0].tool_calls[0]).toEqual({ id: signedId('x', KEY) }); - expect(up.seen[0].headers['accept-encoding']).not.toBe('identity'); - } finally { - await proxy.close(); - await new Promise((resolve) => up.server.close(resolve)); - } - }); - - test('a signed request larger than 64 MiB is refused before it reaches Gemini', async () => { - const up = await startUpstream((_n, res) => res.end('unexpected')); - const { proxy, token } = await startProxy(up.port, createSignatureStore(join(root, 'sigs'))); - try { - const status = await new Promise((resolve, reject) => { - const request = httpRequest({ - hostname: '127.0.0.1', - port: proxy.port, - path: '/v1beta/openai/chat/completions', - method: 'POST', - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, - }, (response) => { - response.resume(); - response.on('end', () => resolve(response.statusCode)); - }); - request.on('error', reject); - request.end(Buffer.alloc(64 * 1024 * 1024 + 1, 0x20)); - }); - expect(status).toBe(413); - expect(up.seen).toHaveLength(0); - } finally { - await proxy.close(); - await new Promise((resolve) => up.server.close(resolve)); - } - }); -}); diff --git a/cli/package-lock.json b/cli/package-lock.json index b4f281e375..65aca4a23d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.153", + "version": "0.1.154", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.153", + "version": "0.1.154", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 599df7858d..ae0b8ab562 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.153", + "version": "0.1.154", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 6d8789fe8b..8776761de4 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -53,7 +53,6 @@ import { resolvePublicSandboxMode } from '../sandbox/mode.js'; import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js'; import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; import { isAnswerableToolError } from '../public-tool-error.js'; -import { createSignatureStore, isGeminiOpenAIUpstream } from '../gemini-signatures.js'; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; const TERMINATION_GRACE_MS = 5000; @@ -585,6 +584,8 @@ const readOpenCodeVersion = (binary, env) => { } }; +const isOpenCodePrerelease = (version) => version.split('+', 1)[0].includes('-'); + const compareOpenCodeVersions = (left, right) => { const leftParts = left.split(/[+-]/, 1)[0].split('.').map(Number); const rightParts = right.split(/[+-]/, 1)[0].split('.').map(Number); @@ -593,6 +594,11 @@ const compareOpenCodeVersions = (left, right) => { return leftParts[index] > rightParts[index] ? 1 : -1; } } + // The smoke-tested boundaries are stable releases. A matching prerelease + // must not inherit their native signature-replay guarantee. + const leftPrerelease = isOpenCodePrerelease(left); + const rightPrerelease = isOpenCodePrerelease(right); + if (leftPrerelease !== rightPrerelease) return leftPrerelease ? -1 : 1; return 0; }; @@ -666,6 +672,16 @@ export default { : childSourceEnv; const binary = ctx._binaryPath || spawnBinaryPath(runtimeEnv); const installedVersion = readOpenCodeVersion(binary, runtimeEnv); + // Trusted seats otherwise allow old binaries. Gemini requires a stable + // release with native signature replay now that the proxy shim is gone. + if (provider && new URL(provider.baseURL).hostname.toLowerCase() === 'generativelanguage.googleapis.com' + && (!installedVersion || isOpenCodePrerelease(installedVersion) + || compareOpenCodeVersions(installedVersion, TESTED_OPENCODE_VERSIONS[0]) < 0)) { + throw new Error( + `Gemini requires stable OpenCode ${TESTED_OPENCODE_VERSIONS[0]} or later for native thought-signature replay; ` + + `installed version is ${installedVersion || 'unknown'}`, + ); + } if (!TESTED_OPENCODE_VERSIONS.includes(installedVersion)) { const mismatch = describeOpenCodeVersionMismatch(installedVersion); const allowOverride = process.env[ALLOW_UNTESTED_OPENCODE_VERSION_ENV] === '1'; @@ -713,26 +729,13 @@ export default { let providerProxy = null; try { openCodeHome = await prepareOpenCodeDataHome(ctx, { publicSeat: isPublic }); - // Gemini 3 needs its thought signatures back on the next request, and - // OpenCode's provider drops them (gemini-signatures.js), so a Gemini seat - // goes through the proxy whatever its trust. The signatures are kept per - // seat under ~/.commonly, outside the workspace and OpenCode's own home. - const geminiUpstream = Boolean(provider) && isGeminiOpenAIUpstream(provider.baseURL); - if ((isPublic || geminiUpstream) && provider) { - let signatureStore = null; - if (geminiUpstream) { - const seat = createHash('sha256').update(seatIdentity(ctx)).digest('hex').slice(0, 24); - signatureStore = createSignatureStore(ctx._geminiSignatureDir - || join(homedir(), '.commonly', 'gemini-signatures', seat)); - await signatureStore.prune(); - } + if (isPublic && provider) { // OpenCode can spend the configured model budget through this proxy; // the bearer only prevents unrelated local processes from using it. providerProxy = await startProviderProxy({ provider, keyFile: providerKeyFile, tokenFile: join(tempDir, 'provider-proxy-token'), - signatureStore, }); } const stateHome = isPublic ? join(openCodeHome.root, 'state') : null; diff --git a/cli/src/lib/gemini-signatures.js b/cli/src/lib/gemini-signatures.js deleted file mode 100644 index 4cc59bed69..0000000000 --- a/cli/src/lib/gemini-signatures.js +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Gemini thought signatures through an OpenAI-compatible client. - * - * Gemini 3 signs the function calls of each step it thinks through and turns - * the next request away without that signature back on the call: 400 - * "Function call is missing a thought_signature in functionCall parts". Its - * OpenAI-compatible API returns the signature beside the call, in - * tool_calls[].extra_content.google.thought_signature, and takes it back - * there. OpenCode's provider (@ai-sdk/openai-compatible) knows no such field - * and drops it, so every Gemini 3 tool loop through OpenCode failed on its - * second request (walked 2026-10-19). - * - * Every Chat client does send a call's id back as it was given. So the proxy - * gives the client an id carrying a short reference to the signature, - * `__cmts16__`, keeps the signature in a store under that key, and on - * the way back takes the reference off the id and puts the signature into - * extra_content. A call whose signature can't be recovered (another model's - * call, an id the client shortened, a store that lost it) goes to Gemini with - * `skip_thought_signature_validator`, the value Google documents for history - * it didn't sign. - * - * The approach is yetone's, from magpie's gateway - * (https://github.com/yetone/magpie, internal/gateway/gemini_signature.go and - * gemini_sigstore.go at a9304efe). This is a separate JavaScript - * implementation for Commonly's per-spawn provider proxy, and magpie's notice - * is reproduced here: - * - * MIT License - * - * Copyright (c) 2026 yetone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import { createHash, randomBytes } from 'crypto'; -import { - mkdir, readdir, readFile, rename, rm, stat, utimes, writeFile, -} from 'fs/promises'; -import { join } from 'path'; -import { StringDecoder } from 'string_decoder'; - -/** The hosts whose OpenAI-compatible API needs signatures carried back. */ -const GEMINI_OPENAI_HOSTS = new Set(['generativelanguage.googleapis.com']); - -export const isGeminiOpenAIUpstream = (baseURL) => { - try { - return GEMINI_OPENAI_HOSTS.has(new URL(baseURL).hostname.toLowerCase()); - } catch { - return false; - } -}; - -export const SKIP_SIGNATURE = 'skip_thought_signature_validator'; - -// The reference in an id: MARK, the key's length, `__`, the key. The length -// tells a whole key from one a client cut short, which then reads as no -// signature rather than as some other key. -const MARK = '__cmts'; -const KEY_BYTES = 12; // 16 base64url characters -const KEY_LENGTH = 16; -const SIGNED_ID_RE = new RegExp(`^([\\s\\S]*?)${MARK}(\\d+)__([A-Za-z0-9_-]*)$`); - -export const signedId = (id, key) => `${id}${MARK}${key.length}__${key}`; - -/** `{ id, key }`: the call's own id and its signature's key, or the id as given and null. */ -export const unsignedId = (id) => { - if (typeof id !== 'string') return { id, key: null }; - const match = SIGNED_ID_RE.exec(id); - if (!match || Number(match[2]) !== match[3].length || match[3].length !== KEY_LENGTH) { - return { id, key: null }; - } - return { id: match[1], key: match[3] }; -}; - -const signatureKey = (signature) => createHash('sha256').update(signature).digest() - .subarray(0, KEY_BYTES).toString('base64url'); - -const SIGNATURE_KEEP_MS = 30 * 24 * 60 * 60 * 1000; - -/** - * Signatures by key: in memory for this spawn, and in `dir` (0700, one 0600 - * file per signature, named by its hash) so a seat's next turn finds the calls - * its earlier turns made. Without a dir it is memory only. A file is written - * whole, staged then renamed. A failed write or read is no signature, which - * Gemini takes as unsigned history. - */ -export const createSignatureStore = (dir = null) => { - const memory = new Map(); - let ready = null; - const ensureDir = () => { - if (!dir) return Promise.resolve(false); - ready = ready || mkdir(dir, { recursive: true, mode: 0o700 }).then(() => true, () => false); - return ready; - }; - return { - async keep(signature) { - const key = signatureKey(signature); - memory.set(key, signature); - if (await ensureDir()) { - const target = join(dir, key); - const staged = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; - try { - await writeFile(staged, signature, { encoding: 'utf8', mode: 0o600 }); - await rename(staged, target); - } catch { - await rm(staged, { force: true }).catch(() => {}); - } - } - return key; - }, - async lookup(key) { - if (memory.has(key)) return memory.get(key); - if (!dir || !/^[A-Za-z0-9_-]{16}$/.test(key)) return null; - try { - const path = join(dir, key); - const signature = await readFile(path, 'utf8'); - if (!signature || signatureKey(signature) !== key) return null; - const now = new Date(); - await utimes(path, now, now).catch(() => {}); - memory.set(key, signature); - return signature; - } catch { - return null; - } - }, - /** Remove signatures not read for 30 days, and staged files left behind. */ - async prune(now = Date.now()) { - if (!dir) return; - let names = []; - try { names = await readdir(dir); } catch { return; } - await Promise.all(names.map(async (name) => { - try { - const details = await stat(join(dir, name)); - const age = now - details.mtimeMs; - if (age > SIGNATURE_KEEP_MS || (name.endsWith('.tmp') && age > 60 * 60 * 1000)) { - await rm(join(dir, name), { force: true }); - } - } catch { /* gone already */ } - })); - }, - }; -}; - -const googleSignature = (call) => { - const signature = call?.extra_content?.google?.thought_signature; - return typeof signature === 'string' && signature ? signature : null; -}; - -/** - * A chat/completions request body for Gemini with the references taken off - * its call ids and each signed step's signature back in extra_content. Gemini - * signs the first call of a step, so only a step with no signature at all gets - * the skip value, on every call. Returns the body unchanged when it is not - * JSON or nothing changed. - */ -export const restoreRequestSignatures = async (body, store) => { - let request; - try { request = JSON.parse(body.toString('utf8')); } catch { return body; } - if (!request || !Array.isArray(request.messages)) return body; - let changed = false; - for (const message of request.messages) { - if (!message || typeof message !== 'object') continue; - if (typeof message.tool_call_id === 'string') { - const { id, key } = unsignedId(message.tool_call_id); - if (key) { message.tool_call_id = id; changed = true; } - } - if (!Array.isArray(message.tool_calls) || message.tool_calls.length === 0) continue; - const signatures = []; - for (const call of message.tool_calls) { - let signature = googleSignature(call); - const { id, key } = unsignedId(call?.id); - if (key) { - call.id = id; - changed = true; - // eslint-disable-next-line no-await-in-loop - signature = signature || await store.lookup(key); - } - signatures.push(signature); - } - const signedStep = signatures.some(Boolean); - message.tool_calls.forEach((call, index) => { - if (!call || typeof call !== 'object') return; - const signature = signatures[index] || (signedStep ? null : SKIP_SIGNATURE); - if (!signature || googleSignature(call) === signature) return; - call.extra_content = { ...(call.extra_content || {}), google: { ...(call.extra_content?.google || {}), thought_signature: signature } }; - changed = true; - }); - } - return changed ? Buffer.from(JSON.stringify(request), 'utf8') : body; -}; - -/** Give each signed call in one reply (or one streamed chunk) an id carrying its key. */ -const signReplyObject = async (reply, store) => { - if (!reply || !Array.isArray(reply.choices)) return false; - let changed = false; - for (const choice of reply.choices) { - for (const field of ['message', 'delta']) { - const calls = choice?.[field]?.tool_calls; - if (!Array.isArray(calls)) continue; - for (const call of calls) { - const signature = googleSignature(call); - if (!signature || typeof call.id !== 'string' || !call.id) continue; - if (unsignedId(call.id).key) continue; - // eslint-disable-next-line no-await-in-loop - call.id = signedId(call.id, await store.keep(signature)); - changed = true; - } - } - } - return changed; -}; - -const SIGNATURE_FIELD = '"thought_signature"'; - -/** - * Rewrites a Gemini chat/completions reply as it streams to the client. An SSE - * reply is rewritten line by line, and a line passes as it came unless it is a - * `data:` chunk carrying a signature; any other reply is rewritten whole at - * the end. `write` and `end` return the bytes to send now. - */ -export const createReplySigner = ({ store, sse }) => { - // A chunk can end inside a multi-byte character; decode across chunks. - const decoder = new StringDecoder('utf8'); - let pending = ''; - const signLine = async (line) => { - if (!line.includes(SIGNATURE_FIELD)) return line; - const body = line.replace(/\r?\n$/, ''); - if (!body.startsWith('data:')) return line; - let chunk; - try { chunk = JSON.parse(body.slice(5).trim()); } catch { return line; } - if (!await signReplyObject(chunk, store)) return line; - return `data: ${JSON.stringify(chunk)}${line.slice(body.length)}`; - }; - const signLines = async (text) => { - if (!text.includes(SIGNATURE_FIELD)) return text; - const lines = text.match(/[^\n]*\n|[^\n]+$/g) || []; - const out = []; - // eslint-disable-next-line no-await-in-loop - for (const line of lines) out.push(await signLine(line)); - return out.join(''); - }; - return { - async write(chunk) { - pending += decoder.write(chunk); - if (!sse) return ''; - const cut = pending.lastIndexOf('\n'); - if (cut < 0) return ''; - const complete = pending.slice(0, cut + 1); - pending = pending.slice(cut + 1); - return signLines(complete); - }, - async end() { - const rest = pending + decoder.end(); - pending = ''; - if (sse) return signLines(rest); - if (!rest.includes(SIGNATURE_FIELD)) return rest; - let reply; - try { reply = JSON.parse(rest); } catch { return rest; } - return await signReplyObject(reply, store) ? JSON.stringify(reply) : rest; - }, - }; -}; diff --git a/cli/src/lib/provider-proxy.js b/cli/src/lib/provider-proxy.js index 60510c4200..7cf05f48c2 100644 --- a/cli/src/lib/provider-proxy.js +++ b/cli/src/lib/provider-proxy.js @@ -5,11 +5,6 @@ import { createServer, request as httpRequest } from 'http'; import { request as httpsRequest } from 'https'; import { chmod, lstat, open, writeFile } from 'fs/promises'; import { isAbsolute, relative, sep } from 'path'; -import { createReplySigner, restoreRequestSignatures } from './gemini-signatures.js'; - -// A chat request is buffered only to restore Gemini's thought signatures; a -// body past this is refused rather than held in memory. -const SIGNED_REQUEST_MAX_BYTES = 64 * 1024 * 1024; const isPathWithin = (parent, candidate) => { const fromParent = relative(parent, candidate); @@ -72,14 +67,8 @@ const readProviderKey = async (keyFile, adapterLabel) => { } }; -/** - * `signatureStore` (gemini-signatures.js) turns on Gemini's thought-signature - * round trip for chat/completions: ids in replies carry a reference, and the - * next request gets the signatures back. Without it every request and reply is - * piped through unchanged, as for every other provider. - */ export const startProviderProxy = async ({ - provider, keyFile, tokenFile, adapterLabel = 'OpenCode', signatureStore = null, + provider, keyFile, tokenFile, adapterLabel = 'OpenCode', }) => { const upstream = new URL(provider.baseURL); if (!['http:', 'https:'].includes(upstream.protocol) @@ -183,9 +172,6 @@ export const startProviderProxy = async ({ ].includes(name.toLowerCase()))); headers.authorization = `Bearer ${upstreamKey}`; headers.host = target.host; - const signing = Boolean(signatureStore) && target.path.endsWith('/chat/completions'); - // The reply is rewritten, so ask for it uncompressed. - if (signing) headers['accept-encoding'] = 'identity'; const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({ protocol: target.protocol, hostname: target.hostname, @@ -203,31 +189,9 @@ export const startProviderProxy = async ({ .filter(([name]) => ![ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', - ...(signing ? ['content-length'] : []), ].includes(name.toLowerCase()))); outgoing.writeHead(response.statusCode || 502, responseHeaders); - if (!signing) { - response.pipe(outgoing); - return; - } - const signer = createReplySigner({ - store: signatureStore, - sse: /text\/event-stream/i.test(String(response.headers['content-type'] || '')), - }); - // Chunks are signed in order; each waits for the one before it. A step - // that fails ends the reply rather than leaving a rejection unhandled, - // which would take the whole CLI process down. - let sent = Promise.resolve(); - const open = () => !outgoing.destroyed && !outgoing.writableEnded; - const send = (produce) => { - sent = sent.then(produce).then((text) => { if (text && open()) outgoing.write(text); }); - return sent; - }; - response.on('data', (chunk) => { send(() => signer.write(chunk)).catch(() => outgoing.destroy()); }); - response.on('end', () => { - send(() => signer.end()).catch(() => {}).finally(() => { if (open()) outgoing.end(); }); - }); - response.on('error', () => outgoing.destroy()); + response.pipe(outgoing); }); request.on('error', () => { if (!outgoing.headersSent) outgoing.writeHead(502); @@ -235,36 +199,7 @@ export const startProviderProxy = async ({ }); outgoing.on('close', () => request.destroy()); incoming.on('aborted', () => request.destroy()); - if (!signing || incoming.method !== 'POST') { - incoming.pipe(request); - return; - } - const parts = []; - let size = 0; - let refused = false; - incoming.on('data', (chunk) => { - if (refused) return; - size += chunk.length; - if (size > SIGNED_REQUEST_MAX_BYTES) { - refused = true; - request.destroy(); - if (!outgoing.headersSent) outgoing.writeHead(413); - outgoing.end(); - return; - } - parts.push(chunk); - }); - incoming.on('end', () => { - if (refused) return; - restoreRequestSignatures(Buffer.concat(parts), signatureStore).then((body) => { - request.setHeader('content-length', body.length); - request.end(body); - }, () => { - request.destroy(); - if (!outgoing.headersSent) outgoing.writeHead(502); - outgoing.end('Provider request failed'); - }); - }); + incoming.pipe(request); }); await new Promise((resolve, reject) => { diff --git a/docs/cli/opencode.md b/docs/cli/opencode.md index bd2e3953a4..7e49b84e04 100644 --- a/docs/cli/opencode.md +++ b/docs/cli/opencode.md @@ -77,19 +77,20 @@ Both times the agent answered an @mention and posted through the Commonly tool. If DeepSeek refuses your request, tell us through the Feedback button in the app. -**Gemini** (CLI 0.1.120 or later). Set `baseURL` to +**Gemini.** Set `baseURL` to `https://generativelanguage.googleapis.com/v1beta/openai` and `model` to `gemini-3.8-flash`, with your Gemini API key in the key file. Gemini 3 sends a -"thought signature" with each tool call and needs it back on the next request, -and OpenCode drops it. So the CLI sends a Gemini seat through its local proxy, -which keeps the signatures in `~/.commonly/gemini-signatures` and puts them -back. We walked a private seat on 2026-10-20: it used its Commonly tools, -answered an @mention and posted, then answered a second @mention from the same -session. A turn can take a minute or two. On earlier CLI versions Gemini -refuses the agent's second request ("Function call is missing a -thought_signature"). A new Gemini key can't use `gemini-2.5-flash` or -`gemini-2.5-flash-lite`. Changing the seat's provider starts a fresh OpenCode -session so Gemini's signed tool-call IDs never go to another provider. +"thought signature" with each tool call and needs it back on the next request. +OpenCode 1.18.35 or later replays that signature natively; the CLI no longer rewrites tool +calls or stores signatures. Private seats connect to Gemini directly. Public +seats still use the local proxy to keep the provider key out of OpenCode's +environment, and that proxy passes request and reply bodies unchanged. The +native replay was checked with a two-turn tool-call fixture on OpenCode +1.18.35, 1.19.0 and 1.19.4 on 2026-10-24. The earlier live Gemini walk was on +2026-10-20. A turn can take a minute or two. A new Gemini key can't use +`gemini-2.5-flash` or `gemini-2.5-flash-lite`. Changing the seat's provider +starts a fresh OpenCode session so Gemini's signed tool-call IDs never go to +another provider. Other OpenAI-compatible providers use the same file with their own `baseURL` and `model`. Apart from DeepSeek and Gemini, none has been walked yet.