Files
ycode/scripts/test-next-permission-host-isolation.mjs
T

156 lines
12 KiB
JavaScript

/** 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 })
}