From e7b75630974f7cd9aac0b21df58dd8f7331dd124 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:41:45 -0700 Subject: [PATCH 1/7] refactor(cli): use OpenCode native Gemini signature replay Co-authored-by: lark Agent-Model: codex/gpt-6-sol/high Agent-Model: codex/gpt-6.1-sol/high --- cli/__tests__/adapters.opencode.test.mjs | 13 +- cli/__tests__/gemini-signatures.test.mjs | 292 ----------------------- cli/src/lib/adapters/opencode.js | 16 +- cli/src/lib/gemini-signatures.js | 277 --------------------- cli/src/lib/provider-proxy.js | 71 +----- docs/cli/opencode.md | 23 +- 6 files changed, 21 insertions(+), 671 deletions(-) delete mode 100644 cli/__tests__/gemini-signatures.test.mjs delete mode 100644 cli/src/lib/gemini-signatures.js diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index a651fe6186..f75b00d497 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -737,11 +737,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 +751,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 +764,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, }); 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/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 6d8789fe8b..55db8b2b7c 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; @@ -713,26 +712,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..35d032edb9 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 now 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. From 5189e335c1bf068320926b56b41b41c479d38eed Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:46:38 -0700 Subject: [PATCH 2/7] chore(cli): bump OpenCode native-signature release to 0.1.144 Co-authored-by: lark Agent-Model: codex/gpt-6-sol/high Agent-Model: codex/gpt-6.1-sol/high --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index 599df7858d..256cb77708 100644 --- a/cli/package.json +++ b/cli/package.json @@ -2,7 +2,7 @@ "name": "@commonlyai/cli", "version": "0.1.153", "license": "Apache-2.0", - "description": "The Commonly CLI — connect agents, manage pods, iterate fast", + "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast", "type": "module", "main": "./src/index.js", "bin": { From 8efdde4a850c0f20f544d22d15d3e57f5cf001d1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:48:17 -0700 Subject: [PATCH 3/7] fix(cli): require native OpenCode signature replay for Gemini Co-authored-by: lark Agent-Model: codex/gpt-6-sol/high Agent-Model: codex/gpt-6.1-sol/high --- cli/__tests__/adapters.opencode.test.mjs | 29 ++++++++++++++++++++++++ cli/src/lib/adapters/opencode.js | 9 ++++++++ docs/cli/opencode.md | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index f75b00d497..7b0f854ee8 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -771,6 +771,35 @@ describe('opencode external provider config', () => { }); }); + test.each([ + ['older', { status: 0, stdout: 'OpenCode 1.18.0\n' }, '1.18.0'], + ['unknown', { status: 1, stdout: '' }, 'unknown'], + ])('a private Gemini seat refuses an %s OpenCode version 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 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/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 55db8b2b7c..69057d7f6f 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -665,6 +665,15 @@ export default { : childSourceEnv; const binary = ctx._binaryPath || spawnBinaryPath(runtimeEnv); const installedVersion = readOpenCodeVersion(binary, runtimeEnv); + // Trusted seats otherwise allow old binaries. Gemini must refuse versions + // that still drop tool-call signatures now that the proxy shim is gone. + if (provider && new URL(provider.baseURL).hostname.toLowerCase() === 'generativelanguage.googleapis.com' + && (!installedVersion || compareOpenCodeVersions(installedVersion, TESTED_OPENCODE_VERSIONS[0]) < 0)) { + throw new Error( + `Gemini requires 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'; diff --git a/docs/cli/opencode.md b/docs/cli/opencode.md index 35d032edb9..7e49b84e04 100644 --- a/docs/cli/opencode.md +++ b/docs/cli/opencode.md @@ -81,7 +81,7 @@ request, tell us through the Feedback button in the app. `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. -OpenCode now replays that signature natively; the CLI no longer rewrites tool +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 From db64d7bfc92dbaf1adfdaec5fd398027c9613b77 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:05:14 -0700 Subject: [PATCH 4/7] test(cli): pin provider proxy byte preservation Co-authored-by: lark Agent-Model: codex/gpt-6-sol/high Agent-Model: codex/gpt-6.1-sol/high --- cli/__tests__/adapters.opencode.test.mjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index 7b0f854ee8..93c00856b4 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}`, From 0f571d6024b1a96e7769e2aaf9fb1b908caf3d73 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:44:51 -0700 Subject: [PATCH 5/7] fix(cli): reject OpenCode prerelease at Gemini replay boundary Co-authored-by: lark Agent-Model: codex/gpt-6-sol/high Agent-Model: codex/gpt-6.1-sol/high --- cli/__tests__/adapters.opencode.test.mjs | 1 + cli/src/lib/adapters/opencode.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index 93c00856b4..c90a5873d1 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -781,6 +781,7 @@ describe('opencode external provider config', () => { test.each([ ['older', { status: 0, stdout: 'OpenCode 1.18.0\n' }, '1.18.0'], + ['prerelease', { status: 0, stdout: 'OpenCode 1.18.35-rc.1\n' }, '1.18.35-rc.1'], ['unknown', { status: 1, stdout: '' }, 'unknown'], ])('a private Gemini seat refuses an %s OpenCode version before spawning', async (_label, result, version) => { await withTemp(async (root) => { diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 69057d7f6f..cc8e04bfa3 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -592,6 +592,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 = left.split('+', 1)[0].includes('-'); + const rightPrerelease = right.split('+', 1)[0].includes('-'); + if (leftPrerelease !== rightPrerelease) return leftPrerelease ? -1 : 1; return 0; }; From 42209524c3c103883bdceacb2dda5cd2a5321387 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:15:18 -0700 Subject: [PATCH 6/7] fix(cli): refuse Gemini on all OpenCode prereleases Co-authored-by: lark Agent-Model: codex/gpt-6-sol/high Agent-Model: codex/gpt-6.1-sol/high --- cli/__tests__/adapters.opencode.test.mjs | 8 +++++--- cli/package.json | 2 +- cli/src/lib/adapters/opencode.js | 15 +++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index c90a5873d1..d1fd43ff8c 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -781,9 +781,11 @@ describe('opencode external provider config', () => { test.each([ ['older', { status: 0, stdout: 'OpenCode 1.18.0\n' }, '1.18.0'], - ['prerelease', { status: 0, stdout: 'OpenCode 1.18.35-rc.1\n' }, '1.18.35-rc.1'], + ['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 an %s OpenCode version before spawning', async (_label, result, version) => { + ])('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'); @@ -804,7 +806,7 @@ describe('opencode external provider config', () => { }, _binaryPath: '/usr/local/bin/opencode', _spawnImpl: spawn.impl, - })).rejects.toThrow(`Gemini requires OpenCode 1.18.35 or later for native thought-signature replay; installed version is ${version}`); + })).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); }); }); diff --git a/cli/package.json b/cli/package.json index 256cb77708..599df7858d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -2,7 +2,7 @@ "name": "@commonlyai/cli", "version": "0.1.153", "license": "Apache-2.0", - "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast", + "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", "main": "./src/index.js", "bin": { diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index cc8e04bfa3..8776761de4 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -584,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); @@ -594,8 +596,8 @@ const compareOpenCodeVersions = (left, right) => { } // The smoke-tested boundaries are stable releases. A matching prerelease // must not inherit their native signature-replay guarantee. - const leftPrerelease = left.split('+', 1)[0].includes('-'); - const rightPrerelease = right.split('+', 1)[0].includes('-'); + const leftPrerelease = isOpenCodePrerelease(left); + const rightPrerelease = isOpenCodePrerelease(right); if (leftPrerelease !== rightPrerelease) return leftPrerelease ? -1 : 1; return 0; }; @@ -670,12 +672,13 @@ export default { : childSourceEnv; const binary = ctx._binaryPath || spawnBinaryPath(runtimeEnv); const installedVersion = readOpenCodeVersion(binary, runtimeEnv); - // Trusted seats otherwise allow old binaries. Gemini must refuse versions - // that still drop tool-call signatures now that the proxy shim is gone. + // 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 || compareOpenCodeVersions(installedVersion, TESTED_OPENCODE_VERSIONS[0]) < 0)) { + && (!installedVersion || isOpenCodePrerelease(installedVersion) + || compareOpenCodeVersions(installedVersion, TESTED_OPENCODE_VERSIONS[0]) < 0)) { throw new Error( - `Gemini requires OpenCode ${TESTED_OPENCODE_VERSIONS[0]} or later for native thought-signature replay; ` + `Gemini requires stable OpenCode ${TESTED_OPENCODE_VERSIONS[0]} or later for native thought-signature replay; ` + `installed version is ${installedVersion || 'unknown'}`, ); } From 0ed8ee9ed30e42500ed5779dd8aa608bfa03acd7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:23:06 -0700 Subject: [PATCH 7/7] chore(cli): reserve 0.1.154 for native OpenCode replay Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",