diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index 2906b278da..57abedbf89 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -734,6 +734,43 @@ 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 () => { + 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-secret-never-inline'); + await chmod(keyFile, 0o600); + const spawn = makeSpawnImpl({ + 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)}}`); + }, + }); + await opencode.spawn('gemini turn', { + cwd: workspace, + env: { PATH: process.env.PATH, XDG_DATA_HOME: join(root, 'operator-data') }, + environment: { + model: 'gemini-3-flash-preview', + provider: { + id: 'google', baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', keyFile, + }, + }, + _geminiSignatureDir: join(root, 'signatures'), + _binaryPath: '/usr/local/bin/opencode', + _spawnImpl: spawn.impl, + }); + expect(spawn.calls).toHaveLength(1); + }); + }); + 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 new file mode 100644 index 0000000000..fc9a35bcd8 --- /dev/null +++ b/cli/__tests__/gemini-signatures.test.mjs @@ -0,0 +1,292 @@ +/** + * 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/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 0e2946af99..8f5a25c89a 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -1660,6 +1660,45 @@ describe('performRun', () => { expect(seenSessionIds).toEqual([null, 'claude-sid-42']); }); + test('OpenCode starts a fresh session when its provider changes, then resumes that provider', async () => { + let eventNumber = 0; + const mockGet = jest.fn(async (route) => { + if (route === '/api/agents/runtime/memory') return { sections: {} }; + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/context')) return { podId: 'pod-abc', revision: 0, focus: null }; + eventNumber += 1; + return { events: [makeEvent({ _id: `provider-turn-${eventNumber}` })] }; + }); + createClient.mockReturnValue({ get: mockGet, post: jest.fn().mockResolvedValue({}) }); + const seenSessionIds = []; + const adapter = { + name: 'opencode', + supportsProvider: true, + detect: stubAdapter.detect, + spawn: jest.fn(async (_prompt, ctx) => { + seenSessionIds.push(ctx.sessionId); + return { text: 'ok', newSessionId: `session-${seenSessionIds.length}` }; + }), + }; + const runWithProvider = async (baseURL) => { + const run = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter, + agentName: 'my-stub', + environment: { provider: { id: 'google', baseURL, keyFile: '/keys/google' } }, + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + run.stop(); + }; + + await runWithProvider('https://generativelanguage.googleapis.com/v1beta/openai'); + await runWithProvider('https://api.other-provider.example/v1'); + await runWithProvider('https://api.other-provider.example/v1'); + expect(seenSessionIds).toEqual([null, null, 'session-2']); + }); + test('clearSessions removes persisted session ids for an agent', async () => { setSession('my-stub', 'pod-a', 'sid-a'); setSession('my-stub', 'pod-b', 'sid-b'); diff --git a/cli/package-lock.json b/cli/package-lock.json index 6b56df2011..988b219f4f 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.119", + "version": "0.1.120", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.119", + "version": "0.1.120", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 857fd23ca3..32626608ef 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.119", + "version": "0.1.120", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 71ad406532..4a362e63cd 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync import { join, dirname, resolve as pathResolve } from 'path'; import { homedir, tmpdir } from 'os'; import { fileURLToPath } from 'url'; +import { createHash } from 'crypto'; import { createClient } from '../lib/api.js'; import { @@ -1241,6 +1242,16 @@ export const performRun = ({ `${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`, ), }); + // OpenCode retains tool-call IDs in its resumed history. A Gemini proxy + // marks those IDs with signature references, so changing providers must + // start a fresh session before another upstream can receive them. + const sessionBinding = adapter?.name === 'opencode' + ? createHash('sha256').update(JSON.stringify([ + seatEnvironment?.provider?.id || null, + seatEnvironment?.provider?.baseURL || null, + seatEnvironment?.provider?.keyFile || null, + ])).digest('hex') + : null; let running = true; // Stop-after-N-auth-failures: without this, a revoked token leaves the // poller hammering 401s forever at 60s backoff — invisible to the user. @@ -1531,7 +1542,7 @@ export const performRun = ({ const runTurn = async ({ event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, }) => { - const sessionId = getSession(agentName, eventPodId); + const sessionId = getSession(agentName, eventPodId, { binding: sessionBinding }); // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, // and (if the adapter returns a summary) patch-sync back after. const memoryLongTerm = await readLongTerm(client, { onError }); @@ -1666,7 +1677,7 @@ export const performRun = ({ } if (result.newSessionId) { - setSession(agentName, eventPodId, result.newSessionId); + setSession(agentName, eventPodId, result.newSessionId, { binding: sessionBinding }); } // Decide whether to post the CLI's final text. The agent owns its posting: // if it already spoke this turn through commonly_post_message, echoing its diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 55db8b2b7c..6d8789fe8b 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -53,6 +53,7 @@ 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; @@ -712,13 +713,26 @@ export default { let providerProxy = null; try { openCodeHome = await prepareOpenCodeDataHome(ctx, { publicSeat: isPublic }); - if (isPublic && provider) { + // 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(); + } // 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 new file mode 100644 index 0000000000..4cc59bed69 --- /dev/null +++ b/cli/src/lib/gemini-signatures.js @@ -0,0 +1,277 @@ +/** + * 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 5458b0d5b9..60510c4200 100644 --- a/cli/src/lib/provider-proxy.js +++ b/cli/src/lib/provider-proxy.js @@ -5,6 +5,11 @@ 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); @@ -67,7 +72,15 @@ const readProviderKey = async (keyFile, adapterLabel) => { } }; -export const startProviderProxy = async ({ provider, keyFile, tokenFile, adapterLabel = 'OpenCode' }) => { +/** + * `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, +}) => { const upstream = new URL(provider.baseURL); if (!['http:', 'https:'].includes(upstream.protocol) || upstream.username || upstream.password || upstream.search || upstream.hash) { @@ -170,6 +183,9 @@ export const startProviderProxy = async ({ provider, keyFile, tokenFile, adapter ].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, @@ -187,9 +203,31 @@ export const startProviderProxy = async ({ provider, keyFile, tokenFile, adapter .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); - response.pipe(outgoing); + 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()); }); request.on('error', () => { if (!outgoing.headersSent) outgoing.writeHead(502); @@ -197,7 +235,36 @@ export const startProviderProxy = async ({ provider, keyFile, tokenFile, adapter }); outgoing.on('close', () => request.destroy()); incoming.on('aborted', () => request.destroy()); - incoming.pipe(request); + 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'); + }); + }); }); await new Promise((resolve, reject) => { diff --git a/cli/src/lib/session-store.js b/cli/src/lib/session-store.js index 3c43fb1343..1e6b147dd2 100644 --- a/cli/src/lib/session-store.js +++ b/cli/src/lib/session-store.js @@ -82,9 +82,11 @@ const writeHandledEvents = (agentName, ids) => { writeFileSync(handledEventsFile(agentName), JSON.stringify(ids, null, 2), 'utf8'); }; -export const getSession = (agentName, podId) => { +export const getSession = (agentName, podId, { binding = null } = {}) => { if (!agentName || !podId) return null; - return readAgent(agentName)[podId]?.sessionId || null; + const entry = readAgent(agentName)[podId]; + if (binding !== null && entry?.binding !== binding) return null; + return entry?.sessionId || null; }; // Read-only diagnostic projection used by the daemon operator surface. A @@ -99,10 +101,14 @@ export const getLastTurn = (agentName) => { .at(-1) || null; }; -export const setSession = (agentName, podId, sessionId) => { +export const setSession = (agentName, podId, sessionId, { binding = null } = {}) => { if (!agentName || !podId) return; const state = readAgent(agentName); - state[podId] = { sessionId, lastTurn: new Date().toISOString() }; + state[podId] = { + sessionId, + lastTurn: new Date().toISOString(), + ...(binding !== null ? { binding } : {}), + }; writeAgent(agentName, state); }; diff --git a/docs/cli/opencode.md b/docs/cli/opencode.md index a956fdd56c..bd2e3953a4 100644 --- a/docs/cli/opencode.md +++ b/docs/cli/opencode.md @@ -77,13 +77,22 @@ 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. -Other OpenAI-compatible providers use the same file with their own `baseURL` -and `model`. Apart from the endpoint above that serves DeepSeek, none has been walked yet. +**Gemini** (CLI 0.1.120 or later). 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. -**Gemini doesn't work this way yet** (walked 2026-10-19). A new Gemini API key -can't use `gemini-2.5-flash` or `gemini-2.5-flash-lite`, and `gemini-3.8-flash` -refused the agent's first tool call on this route ("Function call is missing a -thought_signature"). +Other OpenAI-compatible providers use the same file with their own `baseURL` +and `model`. Apart from DeepSeek and Gemini, none has been walked yet. ## 5. Attach the agent to your pod