From d523b9595d58c80f5c3391858da688750c254f01 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 7 Aug 2026 15:49:04 +0800 Subject: [PATCH] fix(session): restore sandbox-fs after agent-run quiesce Agent runs quiesced stdio MCP extensions including sandbox-fs, but reconcile skipped re-adding missing stdio extensions and treated absent listings as OK. Preserve sandbox-fs across quiesce and re-add other required stdio extensions on the next reconcile so generate_image and write_file stay available. Co-authored-by: Cursor --- session-reconcile.mjs | 5 +- session-reconcile.test.mjs | 90 ++++++++++++++++++++++++++---- session-runtime-lifecycle.mjs | 8 ++- session-runtime-lifecycle.test.mjs | 13 +++-- 4 files changed, 96 insertions(+), 20 deletions(-) diff --git a/session-reconcile.mjs b/session-reconcile.mjs index 3aa5f6c..965c0f5 100644 --- a/session-reconcile.mjs +++ b/session-reconcile.mjs @@ -86,9 +86,6 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) { if (!name) continue; const exists = current.some((ext) => extensionName(ext) === name); if (!exists) { - // goosed may omit active stdio MCP extensions from the session listing - // even though /agent/start already loaded them; do not hot-add stdio. - if (String(config?.type ?? '') === 'stdio') continue; toAdd.push(config); } } @@ -98,7 +95,7 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) { function stdioListingOmissionAllowed(currentExt, desiredConfig) { if (String(desiredConfig?.type ?? '') !== 'stdio') return false; - if (!currentExt) return true; + if (!currentExt) return false; const currentExec = extensionExecutionConfig(currentExt); const desiredExec = extensionExecutionConfig(desiredConfig); return currentExec.type === 'stdio' diff --git a/session-reconcile.test.mjs b/session-reconcile.test.mjs index 7fbe192..8734e0a 100644 --- a/session-reconcile.test.mjs +++ b/session-reconcile.test.mjs @@ -122,22 +122,24 @@ test('extensionsNeedingRefresh adds missing extensions', () => { assert.equal(toAdd[0].name, 'summon'); }); -test('extensionsNeedingRefresh does not hot-add missing stdio extensions', () => { +test('extensionsNeedingRefresh re-adds missing stdio extensions after quiesce', () => { + const desiredSandbox = { + type: 'stdio', + name: 'sandbox-fs', + cmd: '/usr/local/bin/node', + args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'], + available_tools: ['read_file', 'generate_image'], + }; const { toRemove, toAdd } = extensionsNeedingRefresh( [{ name: 'developer', available_tools: ['read_image'] }], [ { name: 'developer', available_tools: ['read_image'] }, - { - type: 'stdio', - name: 'sandbox-fs', - cmd: '/usr/local/bin/node', - args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'], - available_tools: ['read_file'], - }, + desiredSandbox, ], ); assert.deepEqual(toRemove, []); - assert.deepEqual(toAdd, []); + assert.equal(toAdd.length, 1); + assert.equal(toAdd[0].name, 'sandbox-fs'); }); test('extensionPolicyViolations reports unexpected, duplicate, and mismatched extensions', () => { @@ -161,7 +163,7 @@ test('extensionPolicyViolations reports unexpected, duplicate, and mismatched ex ); }); -test('extensionPolicyViolations ignores stdio extensions omitted from goosed listing', () => { +test('extensionPolicyViolations flags stdio extensions missing from goosed listing', () => { assert.deepEqual( extensionPolicyViolations( [{ name: 'developer', available_tools: ['read_image'] }], @@ -186,7 +188,7 @@ test('extensionPolicyViolations ignores stdio extensions omitted from goosed lis { unexpected: [], duplicate: [], - missingOrMismatched: [], + missingOrMismatched: ['sandbox-fs', 'tkmind-search'], }, ); }); @@ -334,6 +336,72 @@ test('reconcileAgentSession restarts after adding missing extensions', async () ]); }); +test('reconcileAgentSession restarts after re-adding quiesced stdio extensions', async () => { + const calls = []; + let extensionReads = 0; + const desiredSandbox = { + type: 'stdio', + name: 'sandbox-fs', + cmd: '/usr/local/bin/node', + args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'], + available_tools: ['write_file', 'generate_image'], + }; + const apiFetch = async (pathname) => { + calls.push(pathname); + if (pathname === '/sessions/session-1') { + return { + ok: true, + text: async () => JSON.stringify({ working_dir: '/valid/workspace' }), + }; + } + if (pathname === '/sessions/session-1/extensions') { + extensionReads += 1; + return { + ok: true, + text: async () => + JSON.stringify({ + extensions: + extensionReads === 1 + ? [{ name: 'developer', available_tools: ['read_image'] }] + : [desiredSandbox, { name: 'developer', available_tools: ['read_image'] }], + }), + }; + } + if ( + pathname === '/agent/add_extension' + || pathname === '/agent/restart' + ) { + return { + ok: true, + text: async () => JSON.stringify({ ok: true }), + }; + } + const harness = harnessMemoryResponse(pathname); + if (harness) return harness; + throw new Error(`unexpected path: ${pathname}`); + }; + + await reconcileAgentSession(apiFetch, 'session-1', { + workingDir: '/valid/workspace', + sessionPolicy: { + extensionOverrides: [ + { name: 'developer', available_tools: ['read_image'] }, + desiredSandbox, + ], + }, + }); + + assert.deepEqual(calls, [ + '/sessions/session-1', + '/sessions/session-1/extensions', + '/agent/add_extension', + '/agent/restart', + '/sessions/session-1/extensions', + '/agent/harness_remember', + '/agent/harness_bootstrap', + ]); +}); + test('reconcileAgentSession fails closed when restart does not apply the requested policy', async () => { const apiFetch = async (pathname) => { if (pathname === '/sessions/session-1') { diff --git a/session-runtime-lifecycle.mjs b/session-runtime-lifecycle.mjs index b0f1fce..66c2c46 100644 --- a/session-runtime-lifecycle.mjs +++ b/session-runtime-lifecycle.mjs @@ -2,6 +2,9 @@ function extensionName(config) { return String(config?.name ?? '').trim(); } +/** Stdio MCP extensions that must survive agent-run quiesce (page write + image gen). */ +export const PRESERVED_STDIO_EXTENSIONS = new Set(['sandbox-fs']); + async function readJson(response) { const text = await response.text(); if (!response.ok) { @@ -52,6 +55,7 @@ export async function cancelSessionActiveRequest(apiFetch, sessionId, requestId) /** * Stop per-session stdio MCP children while preserving the Goose conversation. * Session reconciliation restores the required extensions before the next turn. + * Critical page tools (sandbox-fs) stay attached so the next turn is not tool-less. */ export async function quiesceSessionStdioExtensions(apiFetch, sessionId) { const normalizedSessionId = String(sessionId ?? '').trim(); @@ -60,7 +64,9 @@ export async function quiesceSessionStdioExtensions(apiFetch, sessionId) { const payload = await readJson( await apiFetch(`/sessions/${encodeURIComponent(normalizedSessionId)}/extensions`), ); - const names = sessionStdioExtensionNames(payload?.extensions); + const names = sessionStdioExtensionNames(payload?.extensions).filter( + (name) => !PRESERVED_STDIO_EXTENSIONS.has(name), + ); const removed = []; for (const name of names) { await readJson( diff --git a/session-runtime-lifecycle.test.mjs b/session-runtime-lifecycle.test.mjs index 4549361..c29c1c5 100644 --- a/session-runtime-lifecycle.test.mjs +++ b/session-runtime-lifecycle.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { cancelSessionActiveRequest, + PRESERVED_STDIO_EXTENSIONS, quiesceSessionStdioExtensions, sessionStdioExtensionNames, } from './session-runtime-lifecycle.mjs'; @@ -82,7 +83,7 @@ test('quiesceSessionStdioExtensions removes stdio children without deleting sess const result = await quiesceSessionStdioExtensions(apiFetch, 'session-1'); assert.deepEqual(result, { - removed: ['sandbox-fs', 'tkmind-search'], + removed: ['tkmind-search'], skipped: false, }); assert.deepEqual( @@ -90,23 +91,27 @@ test('quiesceSessionStdioExtensions removes stdio children without deleting sess [ '/sessions/session-1/extensions', '/agent/remove_extension', - '/agent/remove_extension', ], ); assert.deepEqual( calls.slice(1).map(({ init }) => JSON.parse(init.body)), [ - { session_id: 'session-1', name: 'sandbox-fs' }, { session_id: 'session-1', name: 'tkmind-search' }, ], ); + assert.equal(PRESERVED_STDIO_EXTENSIONS.has('sandbox-fs'), true); assert.equal(calls.some(({ pathname }) => pathname.includes('delete')), false); }); test('quiesceSessionStdioExtensions fails when upstream removal is not acknowledged', async () => { const apiFetch = async (pathname) => { if (pathname.endsWith('/extensions')) { - return jsonResponse({ extensions: [{ name: 'sandbox-fs', type: 'stdio' }] }); + return jsonResponse({ + extensions: [ + { name: 'sandbox-fs', type: 'stdio' }, + { name: 'tkmind-search', type: 'stdio' }, + ], + }); } return jsonResponse({ message: 'remove failed' }, { ok: false, status: 500 }); };