/** Offline host integration: real IPC handlers, turn orchestration and queue, inert external ports. */ import assert from 'node:assert/strict' import { readFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { resolve, dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { build } from 'esbuild' const root = process.cwd() const temp = await mkdtemp(join(tmpdir(), 'ycode-next-permission-host-')) const real = new Set(['ipc', 'chat-turn-service', 'prompt-queue', 'agent/mode-policy', 'agent/request-settings', 'agent/queued-request-settings'].map(p => resolve('src/main', `${p}.ts`))) const exportsByModule = new Map() for (const file of ['src/main/ipc.ts', 'src/main/chat-turn-service.ts']) { const source = await readFile(file, 'utf8') for (const match of source.matchAll(/import\s+(?!type\s)([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g)) { const [, bindings, spec] = match if (spec.startsWith('node:') || spec.startsWith('@shared/') || spec.startsWith('../shared/')) continue const key = spec === 'electron' ? spec : resolve(dirname(file), `${spec}.ts`) if (real.has(key)) continue const names = exportsByModule.get(key) ?? new Set() if (bindings.trim().startsWith('{')) { for (const part of bindings.replace(/[{}]/g, '').split(',')) { const name = part.trim().split(/\s+/)[0] if (name && name !== 'type') names.add(name) } } else { const namespace = /\*\s+as\s+(\w+)/.exec(bindings)?.[1] if (!namespace) throw new Error(`Unsupported import ${bindings}`) for (const usage of source.matchAll(new RegExp(`\\b${namespace}\\.(\\w+)`, 'g'))) names.add(usage[1]) } exportsByModule.set(key, names) } } const handlers = new Map() let context = { agentMode: 'craft', permissionMode: 'default-sandbox', modelId: 'test', cwd: temp } let messages = [] let activeInput, completeTurn const policyCalls = [], appendCalls = [] let interactionResponses = 0, attachmentPrepares = 0 const kernel = { busy: false, async setPolicy(permission, mode) { policyCalls.push([permission, mode]) }, runTurn(input) { activeInput = input; this.busy = true; return new Promise(resolve => { completeTurn = () => { this.busy = false; resolve({ content: 'done', tools: [], status: 'done', entryId: 'test-entry' }) } }) }, async appendInput(input) { appendCalls.push(input) } } const mock = (file, value) => mocks[file === 'electron' ? file : resolve('src/main', `${file}.ts`)] = value const mocks = {} mock('electron', { ipcMain: { handle: (name, fn) => handlers.set(name, fn) }, app: { getPath: () => temp }, BrowserWindow: { getAllWindows: () => [] } }) mock('sessions', { listSessions: async () => [{ id: 's' }], getMessages: async () => structuredClone(messages), getSessionContext: async () => ({ ...context }), updateSessionContext: async (_id, patch) => (context = { ...context, ...patch }), getClaudeSession: async () => ({}), startTurn: async (_id, user, assistant, history) => { messages = [...history, user, assistant] }, patchMessage: async (_id, id, patch) => { messages = messages.map(m => m.id === id ? { ...m, ...patch } : m) }, setClaudeFork: async () => {}, clearClaudeFork: async () => {} }) mock('models', { resolveModel: async () => ({ config: { api: 'anthropic-messages' }, profile: { id: 'test', name: 'Test', protocol: 'anthropic' } }) }) mock('agent/claude/claude-kernel', { getClaudeKernel: async () => kernel, cachedClaudeKernel: () => kernel, releaseClaudeKernel: async () => {} }) mock('attachments', { attachmentPrompt: async () => { attachmentPrepares++; return { text: '', images: [] } }, commitAttachments: async () => {} }) mock('personalization', { getPersonalization: async () => ({}), buildPersonalizationPrompt: () => '' }) mock('memory-settings', { getMemorySettings: async () => ({}) }) mock('projects', { ProjectRepository: class {}, buildProjectPrompt: () => '' }) mock('capabilities/skills', { buildSkillContext: async () => '', listSkills: async () => [] }) mock('interaction-broker', { createInteractionBroker: () => ({}), respondToInteraction: () => { interactionResponses++ } }) mock('message-checkpoint', { createMessageCheckpoint: save => ({ schedule: () => {}, finish: save }) }) mock('automations/host', { createAutomationHost: () => ({ api: {} }) }) mock('update-maintenance', { withRuntimeStart: fn => fn() }) mock('workspaces', { workspaceRoot: async () => temp }) mock('config', { getWorkspaceRoot: async () => temp }) // Uninvoked adapter exports are inert; any accidental use throws instead of reaching real services. const inert = function () { if (!new.target) throw new Error('Unexpected external adapter invocation') } for (const [file, names] of exportsByModule) { mocks[file] ??= {} for (const name of names) mocks[file][name] ??= inert } for (const [file, name] of [['browser-host', 'setBrowserHostEventSink'], ['capabilities/recommended-skills', 'subscribeRecommendedSkillInstall'], ['general-settings', 'updateActiveRuns']]) mocks[resolve('src/main', `${file}.ts`)][name] = () => {} mocks[resolve('src/main/library/user-state.ts')].libraryUserStatePath = () => temp mocks[resolve('src/main/context-usage-service.ts')].createContextUsageService = () => ({}) globalThis.__nextPermissionHostMocks = mocks try { await build({ stdin: { contents: "export { registerIpc } from './src/main/ipc'; export { createChatTurnService, runTasks } from './src/main/chat-turn-service'; export { createPromptQueue } from './src/main/prompt-queue'", resolveDir: root, loader: 'ts' }, outfile: join(temp, 'host.mjs'), bundle: true, platform: 'node', format: 'esm', logLevel: 'warning', plugins: [{ name: 'offline-ports', setup(b) { b.onResolve({ filter: /.*/ }, args => { const key = args.path === 'electron' ? 'electron' : args.path.startsWith('.') ? resolve(args.resolveDir, `${args.path}.ts`) : null if (exportsByModule.has(key)) return { path: key, namespace: 'offline' } }) b.onLoad({ filter: /.*/, namespace: 'offline' }, args => ({ contents: [...exportsByModule.get(args.path)].map(name => `export const ${name} = globalThis.__nextPermissionHostMocks[${JSON.stringify(args.path)}][${JSON.stringify(name)}];`).join('\n'), loader: 'js' })) } }] }) const { registerIpc, createChatTurnService, createPromptQueue } = await import(pathToFileURL(join(temp, 'host.mjs')).href) registerIpc(() => null) const update = patch => handlers.get('session:update-context')({}, 's', patch) const service = createChatTurnService({ projects: {}, sessionRoot: async () => temp, broadcastChat: () => {} }) const first = await service.startTurn({ sessionId: 's', text: 'first' }) await new Promise(resolve => setImmediate(resolve)) assert.equal(kernel.busy, true) activeInput.emit({ runId: first.runId, type: 'interaction', interaction: { id: 'approval', kind: 'approval', status: 'pending' } }) await update({ permissionMode: 'full-access' }) assert.equal(context.permissionMode, 'full-access') assert.deepEqual(policyCalls, [['default-sandbox', 'craft']]) assert.equal(interactionResponses, 0) const { runTasks } = await import(pathToFileURL(join(temp, 'host.mjs')).href) assert.equal(runTasks.get(first.runId).snapshot().messages.at(-1).interactions[0].status, 'pending') await assert.rejects(update({ permissionMode: 'invalid' })) await assert.rejects(update({ agentMode: 'ask' })) // Resolve the fake interaction directly (not by the preference change), then test steering mismatch. activeInput.emit({ runId: first.runId, type: 'interaction', interaction: { id: 'approval', kind: 'approval', status: 'completed' } }) const queue = createPromptQueue({ file: join(temp, 'queue.json'), validate: async () => {}, blocked: async () => true, deliver: (p, id) => service.startTurn(p, undefined, id), deliverNow: (p, id) => service.appendToRun(p, id), changed: () => {} }) const queued = await queue.enqueue({ sessionId: 's', text: 'new preference', permissionMode: 'full-access' }) const id = queued.items[0].id const preparedBefore = attachmentPrepares await assert.rejects(queue.sendNow('s', id), error => error.submitted === false && /权限/.test(error.message)) assert.equal(attachmentPrepares, preparedBefore) assert.equal(appendCalls.length, 0) const preserved = await queue.get('s') assert.equal(preserved.items[0].id, id) assert.equal(preserved.items[0].status, 'pending') assert.equal(preserved.items[0].payload.permissionMode, 'full-access') assert.equal(preserved.paused, true) await service.appendToRun({ sessionId: 's', text: 'old captured mode', permissionMode: 'default-sandbox' }, 'old-queued') assert.equal(appendCalls.length, 1) assert.deepEqual(policyCalls, [['default-sandbox', 'craft']]) completeTurn(); assert.equal((await first.completion).status, 'done') const second = await service.startTurn({ sessionId: 's', text: 'next turn' }) await new Promise(resolve => setImmediate(resolve)) assert.deepEqual(policyCalls, [['default-sandbox', 'craft'], ['full-access', 'craft']]) completeTurn(); assert.equal((await second.completion).status, 'done') const capturedOld = await service.startTurn({ sessionId: 's', text: 'captured old queued', permissionMode: 'default-sandbox' }, undefined, 'captured-old') await new Promise(resolve => setImmediate(resolve)) assert.equal(policyCalls.at(-1)[0], 'default-sandbox') assert.equal(context.permissionMode, 'full-access', 'old queue must not overwrite next-send preference') completeTurn(); assert.equal((await capturedOld.completion).status, 'done') // Exercise the actual registered queue IPC validation path for persisted legacy items. const ipcTurn = await handlers.get('chat:send')({}, { sessionId: 's', text: 'IPC full-access run' }) await new Promise(resolve => setImmediate(resolve)) assert.equal(policyCalls.at(-1)[0], 'full-access') const diskQueue = createPromptQueue({ file: join(temp, 'prompt-queues.json'), validate: async () => {}, blocked: async () => true, deliver: async () => { throw new Error('Unexpected automatic delivery') }, changed: () => {} }) const legacy = await diskQueue.enqueue({ sessionId: 's', text: 'legacy missing permission' }) const legacyId = legacy.items[0].id await assert.rejects(handlers.get('chat:queue:send-now')({}, 's', legacyId), error => error.submitted === false && /权限/.test(error.message)) const legacyPreserved = await handlers.get('chat:queue:get')({}, 's') assert.equal(legacyPreserved.items[0].status, 'pending') assert.equal(legacyPreserved.items[0].payload.permissionMode, 'default-sandbox') const capturedNew = await handlers.get('chat:queue:enqueue')({}, { sessionId: 's', text: 'new captured preference' }) assert.equal(capturedNew.items.at(-1).payload.permissionMode, 'full-access') await update({ permissionMode: 'default-sandbox' }) const capturedNewest = await handlers.get('chat:queue:enqueue')({}, { sessionId: 's', text: 'newest preference' }) assert.deepEqual(capturedNewest.items.map(item => item.payload.permissionMode), ['default-sandbox', 'full-access', 'default-sandbox']) assert.equal(policyCalls.at(-1)[0], 'full-access', 'queue and preference changes do not mutate the active policy') const ipcCompletion = runTasks.get(ipcTurn.runId).promise completeTurn(); await ipcCompletion // Queue read shares the repository lock and drains the completion notification. await handlers.get('chat:queue:get')({}, 's') console.log('PASS offline real-host permission isolation: pending approval unchanged, no active setPolicy, next turn updated, queued mode retained, sendNow mismatch preserved without preparation effects') } finally { delete globalThis.__nextPermissionHostMocks await rm(temp, { recursive: true, force: true }) }