feat: persist context snapshots and defer permission changes to next messages
This commit is contained in:
1 parent
607afd2f39
commit
1a913cdb67
19 files changed
+510
-66
No files matched your search
@@ -0,0 +1,155 @@
|
||||
/** 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 })
|
||||
}
|
||||
Reference in new issue
Block a user