From 795fb216e546f63909f8ea7bda77b8abb40ecd88 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:14:00 -0800 Subject: [PATCH 1/6] fix(cli): a public claude seat posts nothing unless its stream shows Commonly working (TASK-256) A public claude seat whose Commonly MCP was down still posted: the model narrated the failure to a stranger. Claude already runs stream-json, and that stream says what is needed (measured on Claude Code 2.1.314 against a stub server): the init event carries each MCP server's status, and a failed call is a tool_result with is_error true whose content is the server's own text. So a public turn is withheld when init does not show the shipped Commonly server connected (including a stream with no init event), or when a Commonly call failed with anything isAnswerableToolError does not name, the predicate the OpenCode adapter already uses. The event stays unacked for retry. Workspace and unconfined seats are not witnessed: they answer their own team. The two codes live beside the predicate so both adapters share them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../adapters.claude.environment.test.mjs | 121 +++++++++++++++++- cli/src/lib/adapters/claude.js | 92 ++++++++++++- cli/src/lib/adapters/opencode.js | 4 +- cli/src/lib/public-tool-error.js | 6 + 4 files changed, 215 insertions(+), 8 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index d84b81816f..8ab163630c 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -39,7 +39,7 @@ const fakeChild = ({ stdout = '', code = 0 } = {}) => { return proc; }; -const makeSpawnImpl = ({ code = 0 } = {}) => { +const makeSpawnImpl = ({ code = 0, stdout = 'ok' } = {}) => { const calls = []; const impl = (cmd, args, opts) => { const configIndex = args.indexOf('--mcp-config'); @@ -56,11 +56,17 @@ const makeSpawnImpl = ({ code = 0 } = {}) => { configMode: configPath ? fs.statSync(configPath).mode & 0o777 : null, configDirMode: configPath ? fs.statSync(path.dirname(configPath)).mode & 0o777 : null, }); - return fakeChild({ stdout: 'ok', code }); + return fakeChild({ stdout, code }); }; return { impl, calls }; }; +// A public seat's turn posts only on a stream whose init shows Commonly connected (TASK-256). +const COMMONLY_CONNECTED = [ + { type: 'system', subtype: 'init', mcp_servers: [{ name: 'commonly', status: 'connected' }] }, + { type: 'result', subtype: 'success', is_error: false, result: 'ok' }, +].map((event) => JSON.stringify(event)).join('\n'); + describe('claude adapter — ctx.environment', () => { let cwd; beforeEach(() => { @@ -636,7 +642,7 @@ describe('claude adapter — ctx.environment', () => { fs.writeFileSync(path.join(home, 'current'), '0.3.13\n'); fs.writeFileSync(path.join(home, '.warm.lock'), 'warming\n'); const prior = process.env.COMMONLY_MCP_HOME; - const { impl, calls } = makeSpawnImpl(); + const { impl, calls } = makeSpawnImpl({ stdout: COMMONLY_CONNECTED }); try { process.env.COMMONLY_MCP_HOME = home; Object.defineProperty(process, 'platform', { value: 'darwin' }); @@ -1764,3 +1770,112 @@ describe('claude adapter — a workspace seat\'s git tool', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('git was not found')); }); }); + +// TASK-256: a public seat posts only when its own stream shows the Commonly MCP +// working. The stream shapes are the ones Claude Code 2.1.314 wrote against a +// stub MCP server: `mcp_servers[].status` in init, and a failed call as a +// `tool_result` with `is_error: true` whose content is the server's text. +describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () => { + const COMMONLY_MCP = [{ name: 'commonly', transport: 'stdio', command: ['npx', '-y', '@commonlyai/mcp@latest'] }]; + const stream = (...events) => events.map((event) => JSON.stringify(event)).join('\n'); + const init = (status) => ({ type: 'system', subtype: 'init', mcp_servers: [{ name: 'commonly', status }] }); + const call = (id, name) => ({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name, input: {} }] } }); + const failed = (id, content) => ({ + type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, is_error: true, content }] }, + }); + const result = (text) => ({ type: 'result', subtype: 'success', is_error: false, result: text }); + const API_503 = JSON.stringify({ status: 503, body: '503 Service Unavailable', message: 'HTTP 503' }); + const API_404 = JSON.stringify({ status: 404, body: { error: 'Pod not found' }, message: 'Pod not found' }); + + let cwd; + let home; + let publicState; + let prior; + const originalPlatform = process.platform; + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-witness-')); + publicState = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-public-state-')); + home = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-mcp-home-')); + const pkgDir = path.join(home, '0.3.13', 'node_modules', '@commonlyai', 'mcp'); + fs.mkdirSync(path.join(pkgDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: '@commonlyai/mcp', version: '0.3.13', bin: { 'commonly-mcp': 'src/index.js' }, + })); + fs.writeFileSync(path.join(pkgDir, 'src', 'index.js'), ''); + fs.writeFileSync(path.join(home, 'current'), '0.3.13\n'); + prior = process.env.COMMONLY_MCP_HOME; + process.env.COMMONLY_MCP_HOME = home; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + spawnSync.mockImplementation((cmd) => (cmd === 'which' + ? { status: 0, stdout: '/usr/bin/true\n' } : { status: 0, stdout: '' })); + }); + afterEach(() => { + if (prior === undefined) delete process.env.COMMONLY_MCP_HOME; + else process.env.COMMONLY_MCP_HOME = prior; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + spawnSync.mockReset(); + for (const dir of [cwd, publicState, home]) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const turn = (stdout, { trust = 'public', mcp = COMMONLY_MCP } = {}) => { + const { impl } = makeSpawnImpl({ stdout }); + return claude.spawn('hi', { + agentName: 'public-test', cwd, + env: { PATH: process.env.PATH, USER: 'safe-user', LOGNAME: 'safe-user' }, + environment: { model: 'test-model', ...(trust ? { sandbox: { mode: 'workspace', trust } } : {}), mcp }, + _publicClaudeState: publicState, + _spawnImpl: impl, + }); + }; + + test('posts when init shows Commonly connected and no Commonly call failed', async () => { + await expect(turn(stream(init('connected'), result('Here you go.')))).resolves.toMatchObject({ text: 'Here you go.' }); + }); + + test.each([ + ['the server failed to start', stream(init('failed'), result('HELLO')), /status failed/], + ['the stream carries no init event', stream(result('HELLO')), /no init event/], + ['the output is plain text', 'HELLO', /no init event/], + ])('withholds the turn when %s', async (_label, stdout, reason) => { + const outcome = turn(stdout); + await expect(outcome).rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + await expect(outcome).rejects.toThrow(reason); + }); + + test.each([ + ['an API 503', API_503], + ['a transport error with no status', 'MCP error -32000: Connection closed'], + ['an empty error', ''], + ['an error as text parts', [{ type: 'text', text: API_503 }]], + ])('withholds the turn when a Commonly call fails with %s', async (_label, content) => { + const stdout = stream(init('connected'), call('t1', 'mcp__commonly__list_pods'), failed('t1', content), result('Every call is failing.')); + await expect(turn(stdout)).rejects.toMatchObject({ code: 'public_tool_failed' }); + }); + + test('posts an answer to a refusal the person can be told about (a 404)', async () => { + const stdout = stream(init('connected'), call('t1', 'mcp__commonly__read_context'), failed('t1', API_404), result('I cannot see that pod.')); + await expect(turn(stdout)).resolves.toMatchObject({ text: 'I cannot see that pod.' }); + }); + + test("ignores another server's failed call", async () => { + const stdout = stream(init('connected'), call('t1', 'mcp__other__lookup'), failed('t1', API_503), result('Done.')); + await expect(turn(stdout)).resolves.toMatchObject({ text: 'Done.' }); + }); + + // As in the OpenCode check: only the shipped server's error contract is known, + // so a server run some other way is not judged by it. + test('a seat that does not run the shipped Commonly MCP is not witnessed', async () => { + const mcp = [{ name: 'commonly', transport: 'stdio', command: ['commonly-mcp'] }]; + await expect(turn(stream(init('failed'), result('HELLO')), { mcp })).resolves.toMatchObject({ text: 'HELLO' }); + }); + + // A record still carrying `internal` is read as public (environment.js), so it is witnessed. + test('a legacy internal-trust seat is witnessed as public', async () => { + await expect(turn(stream(init('failed'), result('HELLO')), { trust: 'internal' })) + .rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + }); + + test.each([['a workspace seat', 'workspace'], ['an unconfined seat', null]])('%s is not witnessed', async (_label, trust) => { + await expect(turn(stream(init('failed'), result('HELLO')), { trust })).resolves.toMatchObject({ text: 'HELLO' }); + }); +}); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index e426c14a10..57e5ee8714 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -97,6 +97,8 @@ import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '. import { skillsAsText } from '../skill-prompt.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js'; +import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; +import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js'; // See codex.js for the rationale on bumping the default + env override. // Keeping both adapters in lockstep so any wrapper agent runtime has the @@ -533,6 +535,78 @@ export const claudeResultText = (stdout, { credentials = [] } = {}) => { .trim(); }; +// TASK-256: what a public seat's own stream says about its Commonly MCP server. +// Claude Code reports each server's state in the init event (`connected` or +// `failed`), and a failed tool call as a `tool_result` with `is_error: true` +// whose content is the server's text, the shipped MCP's `{status, body, +// message}` JSON (both measured on Claude Code 2.1.314). Ready is a positive +// witness: a stream with no init event, or with a Commonly server in any other +// state, never showed the server working, so the turn is withheld. A failed +// Commonly call withholds it unless `isAnswerableToolError` names an answer the +// person can be given (the predicate the OpenCode adapter uses). Only servers +// that run the shipped Commonly MCP count. Returns the failure, or null. +const toolResultText = (content) => { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content.filter((part) => part?.type === 'text').map((part) => String(part.text ?? '')).join(''); +}; + +export const claudePublicMcpWitness = (stdout, mcpServers) => { + const names = (Array.isArray(mcpServers) ? mcpServers : []) + .filter((server) => server?.name && isShippedCommonlyMcpCommand(server.command)) + .map((server) => String(server.name)); + if (!names.length) return null; + const events = String(stdout || '').split('\n').map(parseEvent).filter(Boolean); + const init = events.find((event) => event.type === 'system' && event.subtype === 'init'); + const servers = Array.isArray(init?.mcp_servers) ? init.mcp_servers : []; + for (const name of names) { + const status = servers.find((server) => server?.name === name)?.status; + if (status !== 'connected') { + return { code: PUBLIC_MCP_NOT_READY_CODE, tool: name, error: init ? `status ${status || 'absent'}` : 'no init event' }; + } + } + const toolNames = new Map(); + for (const event of events) { + if (event.type !== 'assistant') continue; + for (const block of event.message?.content || []) { + if (block?.type === 'tool_use' && block.id) toolNames.set(block.id, String(block.name || '')); + } + } + const prefixes = names.map((name) => `mcp__${name}__`); + for (const event of events) { + if (event.type !== 'user' || !Array.isArray(event.message?.content)) continue; + for (const block of event.message.content) { + if (block?.type !== 'tool_result' || block.is_error !== true) continue; + const tool = toolNames.get(block.tool_use_id) || ''; + if (!prefixes.some((prefix) => tool.startsWith(prefix))) continue; + const error = toolResultText(block.content); + if (!isAnswerableToolError(error)) return { code: PUBLIC_TOOL_FAILED_CODE, tool, error }; + } + } + return null; +}; + +const publicWitnessError = ({ code, tool, error }) => { + const reason = String(error).replace(/\s+/g, ' ').slice(0, 160) || '(empty error)'; + const what = code === PUBLIC_MCP_NOT_READY_CODE + ? `Commonly MCP server ${tool} was not shown connected` + : `Commonly tool ${tool} failed in a way the model must not narrate`; + const failure = new Error( + `public seat withheld this turn's reply: ${what} (${reason}), so nothing was posted and the event stays unacked for retry`, + ); + failure.code = code; + return failure; +}; + +// The reply a turn posts. A public seat's turn is witnessed first (TASK-256). +const turnReply = (stdout, { credentials, publicMcp }) => { + if (publicMcp) { + const failure = claudePublicMcpWitness(stdout, publicMcp); + if (failure) throw publicWitnessError(failure); + } + return claudeResultText(stdout, { credentials }); +}; + // The failure tail for a non-zero exit. Stream events are mostly system init // and status lines, which would push the terminal error past the 2000-char cut, // so keep the result text, or the lines that are not system or stream events. @@ -1191,6 +1265,10 @@ export default { // undefined and '' as absence itself; it does not need a guard, it needs // the value. const publicTrust = isConfinedTrust(ctx.environment?.sandbox?.trust); + // Only a stranger-facing seat is witnessed (TASK-256). A workspace seat is + // confined too, but it answers its own team, and an outage it narrates + // there is a signal, not a leak. + const witnessedMcp = ctx.environment?.sandbox?.trust === 'public' ? ctx.environment?.mcp : null; const workCues = publicTrust ? [] : WORK_CUES; const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !isResume, @@ -1330,12 +1408,16 @@ export default { const contextTokens = claudeContextTokens({ cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }), }); - return { text: claudeResultText(stdout, { credentials: spawnCredentials(spawnCtx) }), newSessionId: sessionId, contextTokens }; + return { + text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }), + newSessionId: sessionId, + contextTokens, + }; } catch (err) { // A public seat's refusal is not a session-store failure, whatever its // message happens to contain: it must reach the run loop, never the // fresh-session retry below (#2247). - if (err?.code === PUBLIC_MCP_UNAVAILABLE_CODE) throw err; + if ([PUBLIC_MCP_UNAVAILABLE_CODE, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE].includes(err?.code)) throw err; // Self-heal against a corrupted session store. If the persisted id is // already in use (or claude has forgotten about it), discard it and // restart the turn with a fresh UUID. Without this, a single bad @@ -1393,7 +1475,11 @@ export default { cwd: physicalPath(ctx.cwd), sessionId: freshId, configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }), }); - return { text: claudeResultText(stdout, { credentials: spawnCredentials(retryCtx) }), newSessionId: freshId, contextTokens }; + return { + text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }), + newSessionId: freshId, + contextTokens, + }; } throw err; } finally { diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 62f77c5d15..7c0927b0da 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -56,7 +56,7 @@ import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js'; 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 { isAnswerableToolError, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js'; import { startInstanceProxy } from '../instance-proxy.js'; import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js'; @@ -390,7 +390,7 @@ const makeEventParser = () => { // stays beside the adapter-agnostic readiness witness (TASK-256): it sees the // runtime's own view of each call, and the witness sees a server that never // started, so each catches what the other cannot. -export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed'; +export { PUBLIC_TOOL_FAILED_CODE }; export const publicToolFailure = (toolErrors, mcpServers) => { const prefixes = (Array.isArray(mcpServers) ? mcpServers : []) diff --git a/cli/src/lib/public-tool-error.js b/cli/src/lib/public-tool-error.js index 0fef063d0a..3b0b51b898 100644 --- a/cli/src/lib/public-tool-error.js +++ b/cli/src/lib/public-tool-error.js @@ -33,6 +33,12 @@ export const ANSWERABLE_API_STATUSES = new Set([400, 403, 404, 409, 410, 413, 422]); +// The two ways a public turn is withheld over its Commonly MCP server: a tool +// call failed in a way the model must not narrate, or the server was never shown +// working. Both leave the event unacked for retry. +export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed'; +export const PUBLIC_MCP_NOT_READY_CODE = 'public_mcp_not_ready'; + const INVALID_ARGUMENTS_RE = /^The \S+ tool was called with invalid arguments: /; const parsePayload = (text) => { From 90b01599840f14d7eb71b913a971bd115e0f36a0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:19:06 -0800 Subject: [PATCH 2/6] fix(cli): the witness reads normalised tool prefixes and covers the retry (TASK-256) From the review. Claude Code keeps a server's declared name in init but turns characters outside [A-Za-z0-9_-] into `_` in its tool names (`commonly.me` becomes `mcp__commonly_me__`, measured on 2.1.314), so a failed call from such a server passed. The fresh-session retry path is now tested as well. Also measured: `-p` waits for a slow server (a 6 s initialize) before init, which then reports it connected, not pending. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../adapters.claude.environment.test.mjs | 41 ++++++++++++++++--- cli/src/lib/adapters/claude.js | 8 +++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 8ab163630c..528aadaf0c 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1817,16 +1817,15 @@ describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () = for (const dir of [cwd, publicState, home]) fs.rmSync(dir, { recursive: true, force: true }); }); - const turn = (stdout, { trust = 'public', mcp = COMMONLY_MCP } = {}) => { - const { impl } = makeSpawnImpl({ stdout }); - return claude.spawn('hi', { - agentName: 'public-test', cwd, + const turn = (stdout, { trust = 'public', mcp = COMMONLY_MCP, impl = makeSpawnImpl({ stdout }).impl, sessionId } = {}) => ( + claude.spawn('hi', { + agentName: 'public-test', cwd, ...(sessionId ? { sessionId } : {}), env: { PATH: process.env.PATH, USER: 'safe-user', LOGNAME: 'safe-user' }, environment: { model: 'test-model', ...(trust ? { sandbox: { mode: 'workspace', trust } } : {}), mcp }, _publicClaudeState: publicState, _spawnImpl: impl, - }); - }; + }) + ); test('posts when init shows Commonly connected and no Commonly call failed', async () => { await expect(turn(stream(init('connected'), result('Here you go.')))).resolves.toMatchObject({ text: 'Here you go.' }); @@ -1857,6 +1856,36 @@ describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () = await expect(turn(stdout)).resolves.toMatchObject({ text: 'I cannot see that pod.' }); }); + test('withholds a failed call from a server whose name Claude Code normalises in tool names', async () => { + const mcp = [{ ...COMMONLY_MCP[0], name: 'commonly.me' }]; + const stdout = stream( + { type: 'system', subtype: 'init', mcp_servers: [{ name: 'commonly.me', status: 'connected' }] }, + call('t1', 'mcp__commonly_me__list_pods'), failed('t1', API_503), result('Every call is failing.'), + ); + await expect(turn(stdout, { mcp })).rejects.toMatchObject({ code: 'public_tool_failed' }); + }); + + test('the fresh-session retry is witnessed too', async () => { + // The resumed session is refused, the retry runs fresh, and its server failed. + let spawns = 0; + const impl = () => { + spawns += 1; + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = jest.fn(); + const first = spawns === 1; + setTimeout(() => { + if (first) proc.stderr.emit('data', Buffer.from('Error: Session ID abc-123 is already in use.')); + else proc.stdout.emit('data', Buffer.from(stream(init('failed'), result('HELLO')))); + proc.emit('close', first ? 1 : 0); + }, 0); + return proc; + }; + await expect(turn('', { impl, sessionId: 'abc-123' })).rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + expect(spawns).toBe(2); + }); + test("ignores another server's failed call", async () => { const stdout = stream(init('connected'), call('t1', 'mcp__other__lookup'), failed('t1', API_503), result('Done.')); await expect(turn(stdout)).resolves.toMatchObject({ text: 'Done.' }); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 57e5ee8714..d472296977 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -539,7 +539,8 @@ export const claudeResultText = (stdout, { credentials = [] } = {}) => { // Claude Code reports each server's state in the init event (`connected` or // `failed`), and a failed tool call as a `tool_result` with `is_error: true` // whose content is the server's text, the shipped MCP's `{status, body, -// message}` JSON (both measured on Claude Code 2.1.314). Ready is a positive +// message}` JSON (both measured on Claude Code 2.1.314; `-p` waits for a slow +// server, so init reports it connected rather than pending). Ready is a positive // witness: a stream with no init event, or with a Commonly server in any other // state, never showed the server working, so the turn is withheld. A failed // Commonly call withholds it unless `isAnswerableToolError` names an answer the @@ -572,7 +573,10 @@ export const claudePublicMcpWitness = (stdout, mcpServers) => { if (block?.type === 'tool_use' && block.id) toolNames.set(block.id, String(block.name || '')); } } - const prefixes = names.map((name) => `mcp__${name}__`); + // Init names a server as declared; tool names carry it with every character + // outside [A-Za-z0-9_-] turned to `_` (`commonly.me` → `mcp__commonly_me__`, + // measured on 2.1.314). + const prefixes = names.map((name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`); for (const event of events) { if (event.type !== 'user' || !Array.isArray(event.message?.content)) continue; for (const block of event.message.content) { From f96a4d5d6777c0b881333513c9e9bf799e9fcbea Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:35:43 -0800 Subject: [PATCH 3/6] fix(cli): a pending Commonly server counts as ready once a Commonly call returned CodeRabbit on #3202: init can report a stdio server `pending` while it is still starting. The witness withheld every such turn, including one whose Commonly call then returned. A pending server now counts as ready only with that evidence; no call, another server's call, or a failed Commonly call still withholds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- .../adapters.claude.environment.test.mjs | 30 ++++++++++++++ cli/src/lib/adapters/claude.js | 39 +++++++++++-------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 528aadaf0c..b055cdbbae 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1783,6 +1783,9 @@ describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () = const failed = (id, content) => ({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, is_error: true, content }] }, }); + const succeeded = (id, content = '[]') => ({ + type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content }] }, + }); const result = (text) => ({ type: 'result', subtype: 'success', is_error: false, result: text }); const API_503 = JSON.stringify({ status: 503, body: '503 Service Unavailable', message: 'HTTP 503' }); const API_404 = JSON.stringify({ status: 404, body: { error: 'Pod not found' }, message: 'Pod not found' }); @@ -1851,6 +1854,33 @@ describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () = await expect(turn(stdout)).rejects.toMatchObject({ code: 'public_tool_failed' }); }); + // `pending` at init is not a failure: the server may finish starting during + // the turn. Only a Commonly call that returned shows it working, so that is + // the evidence a pending server needs. + test('posts when init shows Commonly pending and a Commonly call then returned', async () => { + const stdout = stream(init('pending'), call('t1', 'mcp__commonly__list_pods'), succeeded('t1'), result('Two pods.')); + await expect(turn(stdout)).resolves.toMatchObject({ text: 'Two pods.' }); + }); + + test.each([ + ['no Commonly call was made', stream(init('pending'), result('HELLO'))], + ['only another server\'s call returned', stream(init('pending'), call('t1', 'mcp__other__ping'), succeeded('t1'), result('HELLO'))], + ])('withholds the turn when init shows Commonly pending and %s', async (_label, stdout) => { + const outcome = turn(stdout); + await expect(outcome).rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + await expect(outcome).rejects.toThrow(/status pending/); + }); + + test('withholds a pending server whose Commonly call failed, even beside one that returned', async () => { + const stdout = stream( + init('pending'), + call('t1', 'mcp__commonly__list_pods'), succeeded('t1'), + call('t2', 'mcp__commonly__read_context'), failed('t2', API_503), + result('Every call is failing.'), + ); + await expect(turn(stdout)).rejects.toMatchObject({ code: 'public_tool_failed' }); + }); + test('posts an answer to a refusal the person can be told about (a 404)', async () => { const stdout = stream(init('connected'), call('t1', 'mcp__commonly__read_context'), failed('t1', API_404), result('I cannot see that pod.')); await expect(turn(stdout)).resolves.toMatchObject({ text: 'I cannot see that pod.' }); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index d472296977..48f3b537df 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -541,8 +541,10 @@ export const claudeResultText = (stdout, { credentials = [] } = {}) => { // whose content is the server's text, the shipped MCP's `{status, body, // message}` JSON (both measured on Claude Code 2.1.314; `-p` waits for a slow // server, so init reports it connected rather than pending). Ready is a positive -// witness: a stream with no init event, or with a Commonly server in any other -// state, never showed the server working, so the turn is withheld. A failed +// witness: `connected` in init, or `pending` followed by a Commonly call that +// returned, since a server may finish starting during the turn. A stream with no +// init event, or with a Commonly server in any other state, never showed the +// server working, so the turn is withheld. A failed // Commonly call withholds it unless `isAnswerableToolError` names an answer the // person can be given (the predicate the OpenCode adapter uses). Only servers // that run the shipped Commonly MCP count. Returns the failure, or null. @@ -560,12 +562,6 @@ export const claudePublicMcpWitness = (stdout, mcpServers) => { const events = String(stdout || '').split('\n').map(parseEvent).filter(Boolean); const init = events.find((event) => event.type === 'system' && event.subtype === 'init'); const servers = Array.isArray(init?.mcp_servers) ? init.mcp_servers : []; - for (const name of names) { - const status = servers.find((server) => server?.name === name)?.status; - if (status !== 'connected') { - return { code: PUBLIC_MCP_NOT_READY_CODE, tool: name, error: init ? `status ${status || 'absent'}` : 'no init event' }; - } - } const toolNames = new Map(); for (const event of events) { if (event.type !== 'assistant') continue; @@ -573,20 +569,29 @@ export const claudePublicMcpWitness = (stdout, mcpServers) => { if (block?.type === 'tool_use' && block.id) toolNames.set(block.id, String(block.name || '')); } } + const results = events + .filter((event) => event.type === 'user' && Array.isArray(event.message?.content)) + .flatMap((event) => event.message.content) + .filter((block) => block?.type === 'tool_result') + .map((block) => ({ block, tool: toolNames.get(block.tool_use_id) || '' })); // Init names a server as declared; tool names carry it with every character // outside [A-Za-z0-9_-] turned to `_` (`commonly.me` → `mcp__commonly_me__`, // measured on 2.1.314). - const prefixes = names.map((name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`); - for (const event of events) { - if (event.type !== 'user' || !Array.isArray(event.message?.content)) continue; - for (const block of event.message.content) { - if (block?.type !== 'tool_result' || block.is_error !== true) continue; - const tool = toolNames.get(block.tool_use_id) || ''; - if (!prefixes.some((prefix) => tool.startsWith(prefix))) continue; - const error = toolResultText(block.content); - if (!isAnswerableToolError(error)) return { code: PUBLIC_TOOL_FAILED_CODE, tool, error }; + const prefixOf = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`; + for (const name of names) { + const status = servers.find((server) => server?.name === name)?.status; + const returned = status === 'pending' + && results.some(({ block, tool }) => block.is_error !== true && tool.startsWith(prefixOf(name))); + if (status !== 'connected' && !returned) { + return { code: PUBLIC_MCP_NOT_READY_CODE, tool: name, error: init ? `status ${status || 'absent'}` : 'no init event' }; } } + const prefixes = names.map(prefixOf); + for (const { block, tool } of results) { + if (block.is_error !== true || !prefixes.some((prefix) => tool.startsWith(prefix))) continue; + const error = toolResultText(block.content); + if (!isAnswerableToolError(error)) return { code: PUBLIC_TOOL_FAILED_CODE, tool, error }; + } return null; }; From 251386e52a80e041b43078eb55c9cdd933dac325 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:50:08 -0800 Subject: [PATCH 4/6] test(cli): the witness tests start no background MCP warm Every turn in the TASK-256 witness describe kicked a detached `npm install` warm: one test declared a bare `commonly-mcp` command, which resolves through the MCP home with no version. The warm outlived the suite and wrote into the CLI temp-cleanup guard's probe root after Jest ended, so its final rmdir failed with ENOTEMPTY. The describe now seeds a fresh registry answer, and the unshipped case is a hand-run copy (node /src/index.js), as the staging seats actually run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- cli/__tests__/adapters.claude.environment.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index b055cdbbae..4e5ac54435 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1806,6 +1806,10 @@ describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () = })); fs.writeFileSync(path.join(pkgDir, 'src', 'index.js'), ''); fs.writeFileSync(path.join(home, 'current'), '0.3.13\n'); + // A fresh registry answer naming the installed build, so no turn here starts + // a background warm: a detached `npm install` outlives the suite and writes + // into TMPDIR after it ends (the CLI temp-cleanup guard caught it). + fs.writeFileSync(path.join(home, '.registry.json'), JSON.stringify({ version: '0.3.13', checkedAt: Date.now() })); prior = process.env.COMMONLY_MCP_HOME; process.env.COMMONLY_MCP_HOME = home; Object.defineProperty(process, 'platform', { value: 'darwin' }); @@ -1924,7 +1928,8 @@ describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () = // As in the OpenCode check: only the shipped server's error contract is known, // so a server run some other way is not judged by it. test('a seat that does not run the shipped Commonly MCP is not witnessed', async () => { - const mcp = [{ name: 'commonly', transport: 'stdio', command: ['commonly-mcp'] }]; + // A hand-run copy, as the staging seats run: not the published package. + const mcp = [{ name: 'commonly', transport: 'stdio', command: ['node', '/opt/commonly-mcp-staging/src/index.js'] }]; await expect(turn(stream(init('failed'), result('HELLO')), { mcp })).resolves.toMatchObject({ text: 'HELLO' }); }); From 8dbd2b302e517b13840aac98c48d89c767160b94 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:05:03 -0800 Subject: [PATCH 5/6] fix(cli): a public witness failure classes by its code, never by the tool's text (TASK-256) publicWitnessError quotes up to 160 chars of the tool's own error, and classifySpawnFailure tested that text for refusal, quota and auth words first. A vendor answering "content_policy_violation" made a withheld turn an acked refusal; "insufficient_quota" or "403 forbidden" put the seat on a cooldown. The code is ours and decides: runtime, so the event stays unacked and is retried. From CodeRabbit's outside-diff point at 251386e5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- cli/__tests__/spawn-retry.test.mjs | 23 +++++++++++++++++++++++ cli/src/lib/spawn-retry.js | 8 ++++++++ 2 files changed, 31 insertions(+) diff --git a/cli/__tests__/spawn-retry.test.mjs b/cli/__tests__/spawn-retry.test.mjs index fe639c874a..a0d4c68cb0 100644 --- a/cli/__tests__/spawn-retry.test.mjs +++ b/cli/__tests__/spawn-retry.test.mjs @@ -322,3 +322,26 @@ describe('classifies the possessive-exhaustion phrase (2026-08-25, third miss)', }); }); }); + +// TASK-256: a public seat's withheld turn carries the tool's own error text in +// its message, so a vendor that says "content_policy_violation", "quota" or +// "403 forbidden" must not decide the class. The code does: the event stays +// unacked and is retried, never acked as refused. +describe('a public witness failure is a runtime failure, whatever its text says', () => { + test.each([ + ['public_tool_failed', '{"status":500,"message":"content_policy_violation"}'], + ['public_tool_failed', 'this content was flagged'], + ['public_tool_failed', 'insufficient_quota'], + ['public_tool_failed', '403 forbidden'], + ['public_mcp_not_ready', 'rate limit while connecting'], + ])('%s with "%s" classes as runtime', (code, reason) => { + const error = new Error(`public seat withheld this turn's reply: Commonly tool x failed (${reason})`); + error.code = code; + expect(classifySpawnFailure(error)).toBe(SPAWN_FAILURE_CLASS.RUNTIME); + }); + + test('positive control: the same text without the code still classes as a refusal', () => { + expect(classifySpawnFailure(new Error('{"status":500,"message":"content_policy_violation"}'))) + .toBe(SPAWN_FAILURE_CLASS.CONTENT_REFUSED); + }); +}); diff --git a/cli/src/lib/spawn-retry.js b/cli/src/lib/spawn-retry.js index 527850e395..8c69654797 100644 --- a/cli/src/lib/spawn-retry.js +++ b/cli/src/lib/spawn-retry.js @@ -7,6 +7,8 @@ * retry policy here so every adapter gets the same bounded behavior. */ +import { PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from './public-tool-error.js'; + export const SPAWN_FAILURE_CLASS = Object.freeze({ // The provider refused the request's CONTENT. Re-sending the same content // can only be refused again, so this class is never retried: the run loop @@ -120,6 +122,12 @@ const errorText = (error) => [ .join('\n'); export const classifySpawnFailure = (error) => { + // A public seat's withheld turn (TASK-256) quotes the tool's own error, which + // is vendor text: the code decides, so the event stays unacked for retry and + // is never acked as refused or parked behind a quota cooldown by that text. + if (error?.code === PUBLIC_TOOL_FAILED_CODE || error?.code === PUBLIC_MCP_NOT_READY_CODE) { + return SPAWN_FAILURE_CLASS.RUNTIME; + } const text = errorText(error); // First: the refusal text carries none of the words below today, but a // refusal must never be read as a transient condition and retried. From b50535287038e627f391226d36f48e82fc163bc5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:13:47 -0800 Subject: [PATCH 6/6] fix(cli): a witness-coded throttle keeps its rate-limit backoff (TASK-256) The previous commit forced every withheld public turn to runtime, which also turned a Commonly 429 into a 5s retry that meets the same 429. A throttle (status 429 or rate-limit wording) now keeps RATE_LIMIT, which still retries the unacked event; only the refusal, quota and configuration classes stay out of reach of the tool's text. From CodeRabbit's thread at 8dbd2b30. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpQ2pr6xRMhN5mcfoHF --- cli/__tests__/spawn-retry.test.mjs | 13 ++++++++++++- cli/src/lib/spawn-retry.js | 9 ++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/spawn-retry.test.mjs b/cli/__tests__/spawn-retry.test.mjs index a0d4c68cb0..0099d5419f 100644 --- a/cli/__tests__/spawn-retry.test.mjs +++ b/cli/__tests__/spawn-retry.test.mjs @@ -333,13 +333,24 @@ describe('a public witness failure is a runtime failure, whatever its text says' ['public_tool_failed', 'this content was flagged'], ['public_tool_failed', 'insufficient_quota'], ['public_tool_failed', '403 forbidden'], - ['public_mcp_not_ready', 'rate limit while connecting'], + ['public_mcp_not_ready', 'connection reset'], ])('%s with "%s" classes as runtime', (code, reason) => { const error = new Error(`public seat withheld this turn's reply: Commonly tool x failed (${reason})`); error.code = code; expect(classifySpawnFailure(error)).toBe(SPAWN_FAILURE_CLASS.RUNTIME); }); + // A throttle is still a throttle: it keeps the event unacked like runtime, and + // the longer backoff is the point, since a quick retry meets the same 429. + test.each([ + ['public_tool_failed', '{"status":429,"message":"Too Many Requests"}'], + ['public_mcp_not_ready', 'rate limit while connecting'], + ])('%s with "%s" keeps the rate-limit backoff', (code, reason) => { + const error = new Error(`public seat withheld this turn's reply: Commonly tool x failed (${reason})`); + error.code = code; + expect(classifySpawnFailure(error)).toBe(SPAWN_FAILURE_CLASS.RATE_LIMIT); + }); + test('positive control: the same text without the code still classes as a refusal', () => { expect(classifySpawnFailure(new Error('{"status":500,"message":"content_policy_violation"}'))) .toBe(SPAWN_FAILURE_CLASS.CONTENT_REFUSED); diff --git a/cli/src/lib/spawn-retry.js b/cli/src/lib/spawn-retry.js index 8c69654797..4e03a5dd4b 100644 --- a/cli/src/lib/spawn-retry.js +++ b/cli/src/lib/spawn-retry.js @@ -122,13 +122,16 @@ const errorText = (error) => [ .join('\n'); export const classifySpawnFailure = (error) => { + const text = errorText(error); // A public seat's withheld turn (TASK-256) quotes the tool's own error, which // is vendor text: the code decides, so the event stays unacked for retry and - // is never acked as refused or parked behind a quota cooldown by that text. + // is never acked as refused or parked behind a quota or configuration + // cooldown by that text. A throttle keeps its backoff, which also retries. if (error?.code === PUBLIC_TOOL_FAILED_CODE || error?.code === PUBLIC_MCP_NOT_READY_CODE) { - return SPAWN_FAILURE_CLASS.RUNTIME; + return error?.status === 429 || RATE_LIMIT_RE.test(text) + ? SPAWN_FAILURE_CLASS.RATE_LIMIT + : SPAWN_FAILURE_CLASS.RUNTIME; } - const text = errorText(error); // First: the refusal text carries none of the words below today, but a // refusal must never be read as a transient condition and retried. if (CONTENT_REFUSED_RE.test(text)) return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;