import assert from 'node:assert/strict' import { promises as fs } from 'node:fs' import { join, resolve } from 'node:path' import { configureSandbox } from '../src/main/sandbox/configuration' import { executeSandboxCommand } from '../src/main/sandbox/service' import { workspaceAction } from '../src/main/sandbox/workspace-service' import { workspaceKey, workspaceRecord, type WorkspaceRevision } from '../src/main/sandbox/workspace' import { sandboxOperation } from '../src/main/sandbox/maintenance' import { executeTool } from '../src/main/agent/tools' import { executeConversationTool } from '../src/main/agent/conversation-tools' import { readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store' import { importSandboxToolchain } from '../src/main/sandbox/toolchains' import { importSandboxDependencies } from '../src/main/sandbox/dependency-cache' import { spawn, execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { restoreTransaction } from '../src/main/sandbox/transactions' if (process.argv[2] === '--worker') { const [, , , childWorkspace, childState, childSession] = process.argv configureSandbox({ stateRoot: childState, resourceDirectory: resolve('resources/sandbox/win32-x64') }) await executeSandboxCommand('git branch lost-on-crash && echo interrupted>crash.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 25"', { workspaceRoot: childWorkspace, sessionId: childSession, workspaceMode: 'persistent', runtimes: ['git'] }) process.exit(0) } if (process.platform !== 'win32') throw new Error('Persistent workspace requires real Windows isolation') await fs.mkdir(resolve('.tmp'), { recursive: true }) const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-workspace-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state') await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'file.txt'), 'host-before') configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') }) let approvals = 0 const context = { workspaceRoot: workspace, sessionId: 'persistent-session', runId: 'persistent-run', toolCallId: 'persistent-call', approve: async () => { approvals++; return true } } const run = (command: string, extra: Partial[1]> = {}) => executeSandboxCommand(command, { ...context, workspaceMode: 'persistent', ...extra }) const checks: Array<{ label: string; pass: boolean }> = [] function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) } const root = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId)) async function revision(): Promise { const active = await workspaceRecord<{ id: string }>(join(root, 'active.json')) return workspaceRecord(join(root, 'revisions', `${active.id}.json`)) } try { let result = await run('echo draft-value>file.txt') check('successful command saves a draft without host writes or approval', result.ok && result.data.writeback === 'deferred' && approvals === 0 && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-before') const first = await revision() result = await run('type file.txt') const second = await revision() check('next real native command reads the retained draft', result.ok && result.data.stdout.includes('draft-value') && result.data.workspace?.changes.length === 1) check('normal command boundaries preserve the exact physical worktree identity', first.physicalIdentity === second.physicalIdentity && first.id !== second.id) const outcome = await sandboxOperation(result.data.runId) check('durable run outcome distinguishes a deferred draft from host commit', outcome.writeback === 'deferred' && outcome.workspace?.sessionId === context.sessionId && outcome.workspace.revision === second.id) let status = await workspaceAction('changes', context) check('change inspection reports the exact pending host change', status.state === 'ready' && status.changes.length === 1 && status.changes[0].path === 'file.txt') let committed = await workspaceAction('commit', context, status.revision) check('explicit commit consumes one approval and uses a native host transaction', committed.writeback === 'committed' && !!committed.transactionId && approvals === 1 && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).trim() === 'draft-value') check('commit advances the host baseline and clears pending changes', committed.changes.length === 0 && committed.lastTransactionId === committed.transactionId) result = await run('type file.txt') check('read-only command after commit leaves the draft clean', result.ok && result.data.writeback === 'unchanged' && approvals === 1) await run('echo pending>pending.txt') committed = await workspaceAction('commit', { ...context, approve: async () => false }) check('rejected commit keeps draft bytes and leaves host untouched', committed.writeback === 'rejected' && committed.changes.some(change => change.path === 'pending.txt') && !await fs.stat(join(workspace, 'pending.txt')).catch(() => null)) status = await workspaceAction('discard', context) result = await run('if exist pending.txt (exit /b 9) else (type file.txt)') check('discard archives the draft and rebuilds from the current host', status.state === 'ready' && status.changes.length === 0 && result.ok && result.data.stdout.includes('draft-value') && (await fs.readdir(join(root, 'archives'))).some(name => name.startsWith('discarded-'))) await run('echo private-again>file.txt') await fs.writeFile(join(workspace, 'external.txt'), 'external-clean') result = await run('type file.txt && type external.txt') check('three-way synchronization imports unrelated host edits without losing draft changes', result.ok && result.data.stdout.includes('private-again') && result.data.stdout.includes('external-clean') && result.data.workspace?.changes.length === 1) await fs.writeFile(join(workspace, 'file.txt'), 'host-conflict') await assert.rejects(run('echo must-not-run>blocked.txt'), /编辑冲突/) status = await workspaceAction('changes', context) check('same-file host and draft edits block execution and commit with explicit conflicts', status.state === 'conflict' && status.conflicts?.includes('file.txt') && !await fs.stat(join(workspace, 'blocked.txt')).catch(() => null) && await fs.readFile(join(workspace, 'file.txt'), 'utf8') === 'host-conflict') await workspaceAction('discard', context) await run('echo saved-before-failure>checkpoint.txt') result = await run('echo unsuccessful>checkpoint.txt && echo unsuccessful>failed.txt && exit /b 7') check('failed commands do not publish their private filesystem changes', !result.ok && result.data.exitCode === 7 && !await fs.stat(join(workspace, 'failed.txt')).catch(() => null)) result = await run('type checkpoint.txt && if exist failed.txt exit /b 9') check('failed command rollback restores the last durable draft checkpoint', result.ok && result.data.stdout.includes('saved-before-failure')) const controller = new AbortController(), cancel = setTimeout(() => controller.abort(), 1100) try { result = await run('echo cancelled>checkpoint.txt && echo CANCELLATION_MUTATED && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 12"', { signal: controller.signal }) } finally { clearTimeout(cancel) } check('cancelled command returns native cancellation and retains a draft view', !result.ok && result.data.status === 'cancelled' && result.data.workspace?.state === 'ready') result = await run('type checkpoint.txt') check('cancellation restores the last saved private bytes', result.ok && result.data.stdout.includes('saved-before-failure')) result = await run('echo timed-out>checkpoint.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', { timeoutMs: 1000 }) check('native timeout discards private changes without writing the host', !result.ok && result.data.status === 'timeout' && !await fs.stat(join(workspace, 'checkpoint.txt')).catch(() => null)) await assert.rejects(run('echo forbidden>.env'), /受保护|链接/) result = await run('if exist .env (exit /b 9) else (type checkpoint.txt)') check('protected private outputs reject the whole draft candidate and roll back', result.ok && result.data.stdout.includes('saved-before-failure')) const other = await run('if exist checkpoint.txt (exit /b 9) else (echo OTHER_SESSION_CLEAN)', { sessionId: 'other-session' }) check('another session starts from the host and cannot inherit pending private files', other.ok && other.data.stdout.includes('OTHER_SESSION_CLEAN')) const privateRead = await executeSandboxCommand(`type "${join(root, 'worktree/checkpoint.txt')}"`, { ...context, sessionId: 'other-session' }) check('a different AppContainer cannot read the sealed retained tree by absolute path', !privateRead.ok && !privateRead.data.stdout.includes('saved-before-failure')) const current = await revision(), privateBlob = current.tree.find(entry => entry.path === 'checkpoint.txt')! result = await run(`type "${join(root, 'blobs', privateBlob.hash!)}"`) check('even a new owner-session Job cannot directly read host-only checkpoint blobs', !result.ok && !result.data.stdout.includes('saved-before-failure')) await fs.rename(join(root, 'worktree'), join(root, 'replaced-physical')) await fs.cp(join(root, 'replaced-physical'), join(root, 'worktree'), { recursive: true, force: false, errorOnExist: true }) await assert.rejects(run('echo must-not-start>root-replaced.txt'), /物理目录身份/) await workspaceAction('recover', context) result = await run('type checkpoint.txt') check('physical tree replacement is rejected and explicit recovery restores a verified checkpoint', result.ok && result.data.stdout.includes('saved-before-failure') && !await fs.stat(join(workspace, 'root-replaced.txt')).catch(() => null)) let tool = await executeTool('execute_command', { command: 'echo tool-route>tool.txt', workspace: 'persistent' }, context) check('model-facing command selects the real persistent backend', tool.ok && (tool.data as { writeback?: string }).writeback === 'deferred' && !await fs.stat(join(workspace, 'tool.txt')).catch(() => null)) tool = await executeTool('sandbox_workspace', { action: 'changes' }, context) check('model-facing workspace tool inspects pending draft changes', tool.ok && (tool.data as { changes: Array<{ path: string }> }).changes.some(change => change.path === 'tool.txt')) tool = await executeTool('sandbox_workspace', { action: 'status', sessionId: 'other-session', workspaceRoot: stateRoot }, context) check('workspace tool rejects caller-supplied ownership and path overrides', !tool.ok && /归属参数/.test(tool.content)) tool = await executeTool('execute_command', { command: 'echo explicit-native>explicit-mode.txt', workspace: 'persistent' }, { ...context, permissionMode: 'full-access' }) check('explicit persistent mode keeps native confinement even with full-access host context', tool.ok && (tool.data as { sandbox?: string }).sandbox === 'windows-appcontainer' && !await fs.stat(join(workspace, 'explicit-mode.txt')).catch(() => null)) const deniedMode = await executeConversationTool('sandbox_workspace', { action: 'commit' }, { ...context, agentMode: 'plan', signal: new AbortController().signal, emit: () => undefined }) check('planning mode cannot invoke workspace commit or recovery operations', !deniedMode.ok && /模式不允许/.test(deniedMode.content)) await assert.rejects(workspaceAction('discard', context, first.id), /版本已变化/) check('stale revision cannot discard a newer private draft', (await workspaceAction('status', context)).changes.some(change => change.path === 'tool.txt')) const cli = JSON.parse(execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'workspace-changes', '--workspace', workspace, '--session', context.sessionId, '--state-root', stateRoot], { windowsHide: true, encoding: 'utf8', timeout: 30_000 })) check('fresh maintenance process reloads the exact saved owner and revision at Unicode caret paths', cli.sessionId === context.sessionId && cli.revision === (await revision()).id && cli.changes.some((change: { path: string }) => change.path === 'tool.txt')) await run('git status --short', { runtimes: ['git'] }) result = await run('echo git-draft>git.txt && git add git.txt && git commit -q -m persistent-private-commit', { runtimes: ['git'] }) check('draft Git commits persist without a host write approval', result.ok && result.data.git?.scope === 'draft' && result.data.git.persistence === 'saved' && !await fs.stat(join(workspace, 'git.txt')).catch(() => null)) result = await run('git log -1 --oneline && type git.txt', { runtimes: ['git'] }) check('next command sees both private Git history and private worktree bytes', result.ok && result.data.stdout.includes('persistent-private-commit') && result.data.stdout.includes('git-draft')) result = await run('git log --all --oneline', { runtimes: ['git'], sessionId: 'other-session' }) check('private Git history is isolated by session as well as project', result.ok && !result.data.stdout.includes('persistent-private-commit')) const beforeCrash = await revision() const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) let workerError = ''; worker.stderr.on('data', (bytes: Buffer) => { workerError = (workerError + bytes.toString()).slice(-4000) }) const workerClosed = new Promise((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) }) let marker: { runId: string } | undefined for (let attempt = 0; attempt < 240; attempt++) { marker = await workspaceRecord<{ runId: string }>(join(root, 'checkout.json')).catch(() => undefined) if (marker && await fs.stat(join(stateRoot, 'runs', marker.runId, 'stage/workspace/crash.txt')).catch(() => null)) break if (worker.exitCode !== null) throw new Error(`Worker exited before mutation: ${workerError}`) await new Promise(done => setTimeout(done, 40)) } assert.ok(marker, workerError); worker.kill(); await workerClosed let recovered = false for (let attempt = 0; attempt < 60; attempt++) { try { await workspaceAction('recover', context); recovered = true; break } catch (error) { if (!/活跃|活动|未清理/.test((error as Error).message)) throw error; await new Promise(done => setTimeout(done, 60)) } } check('explicit recovery reclaims a dead main-process lock and checks the native lease', recovered && !await fs.stat(join(root, 'checkout.json')).catch(() => null)) result = await run('git branch --list lost-on-crash && if exist crash.txt (exit /b 9) else (type git.txt)', { runtimes: ['git'] }) check('actual host-process death restores prior draft and private Git revision', result.ok && result.data.stdout.includes('git-draft') && !result.data.stdout.includes('lost-on-crash') && !await fs.stat(join(workspace, 'crash.txt')).catch(() => null) && beforeCrash.tree.some(entry => entry.path === 'git.txt')) await workspaceAction('discard', context) result = await run('git log --all --oneline && if exist git.txt exit /b 9', { runtimes: ['git'] }) check('discard archives private history and removes uncommitted private files', result.ok && !result.data.stdout.includes('persistent-private-commit')) await run('echo committed-despite-metadata-fault>recovery-host.txt') const activePath = join(root, 'active.json'), savedActive = join(root, 'held-active.json') committed = await workspaceAction('commit', { ...context, approve: async () => { await fs.rename(activePath, savedActive); await fs.mkdir(activePath); return true } }) check('post-host-commit metadata failure preserves the actual committed outcome', committed.writeback === 'committed' && committed.state === 'interrupted' && (await fs.readFile(join(workspace, 'recovery-host.txt'), 'utf8')).includes('committed-despite-metadata-fault')) await fs.rmdir(activePath); await fs.rename(savedActive, activePath) status = await workspaceAction('recover', context) check('recovery resolves the native committed transaction before advancing the draft baseline', status.state === 'ready' && status.changes.length === 0 && status.lastTransactionId === committed.transactionId) await fs.writeFile(join(workspace, 'private.txt'), 'DUMMY_POLICY_FILTERED_CONTENT') await run('type private.txt', { runtimes: ['git'] }) const policy = await readSandboxPolicy() await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['private.txt'] } }) status = await workspaceAction('status', context) await assert.rejects(run('type private.txt'), /策略.*已变化/) check('policy change makes the old draft stale without returning denied filenames', status.state === 'stale' && status.changes.length === 0) await workspaceAction('discard', context) result = await run('if exist private.txt (exit /b 9) else (git log --all --oneline)', { runtimes: ['git'] }) check('discard under new policy rebuilds filtered files and resets old private history', result.ok && !result.data.stdout.includes('DUMMY_POLICY_FILTERED_CONTENT')) const policy2 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy2.revision, rules: { deniedPaths: [], writeback: 'deny' } }) await workspaceAction('discard', context) result = await run('echo policy-private>denied-host.txt') await assert.rejects(workspaceAction('commit', context), /禁止写回/) check('writeback-deny permits private drafting while refusing host commit', result.ok && result.data.writeback === 'deferred' && !await fs.stat(join(workspace, 'denied-host.txt')).catch(() => null)) const policy3 = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy3.revision, rules: { writeback: 'ask' } }) await workspaceAction('discard', context) await fs.writeFile(join(workspace, 'package.json'), '{"name":"workspace-test","version":"1.0.0"}') await fs.writeFile(join(workspace, 'package-lock.json'), '{"name":"workspace-test","lockfileVersion":3}') await fs.mkdir(join(workspace, 'node_modules/offline-fixture'), { recursive: true }) await fs.writeFile(join(workspace, 'node_modules/offline-fixture/index.js'), 'module.exports="DEPENDENCY_OK"') await importSandboxToolchain('node', process.execPath) await importSandboxDependencies(workspace, 'node') result = await run('node -e "require(\'fs\').writeFileSync(\'node-result.txt\',require(\'offline-fixture\'))"', { runtimes: ['node'], dependencies: ['node'] }) check('read-only dependency injection works in the retained physical tree', result.ok && result.data.toolchains?.[0].kind === 'node' && !await fs.stat(join(root, 'worktree/node_modules')).catch(() => null)) result = await run('type node-result.txt') check('dependency layers detach before checkpoint without losing project outputs', result.ok && result.data.stdout.includes('DEPENDENCY_OK')) const privateCheckpoint = await revision() await fs.writeFile(join(root, 'worktree/node-result.txt'), 'incomplete-private-sync') await fs.writeFile(join(root, 'mutation.json'), JSON.stringify({ version: 1, revision: privateCheckpoint.id })) await assert.rejects(run('echo unsafe>must-not-run.txt'), /中断|未确定/) await workspaceAction('recover', context) result = await run('type node-result.txt') check('private sync intent blocks reuse until explicit checkpoint recovery', result.ok && result.data.stdout.includes('DEPENDENCY_OK') && !await fs.stat(join(workspace, 'must-not-run.txt')).catch(() => null)) const completed = await revision() await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: result.data.runId, revision: completed.previousRevision, physicalIdentity: completed.physicalIdentity })) await workspaceAction('recover', context) check('recovery accepts a completed checkpoint whose checkout acknowledgement was interrupted', (await revision()).id === completed.id && !await fs.stat(join(root, 'checkout.json')).catch(() => null)) const failedAgain = await run('echo incomplete>node-result.txt && exit /b 7'), restored = await revision() await fs.writeFile(join(root, 'checkout.json'), JSON.stringify({ version: 1, runId: failedAgain.data.runId, revision: restored.previousRevision, physicalIdentity: privateCheckpoint.physicalIdentity })) await workspaceAction('recover', context) check('recovery is repeatable after rollback checkpoint publication before marker removal', (await revision()).id === restored.id) await fs.mkdir(join(workspace, 'source-tree/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'source-tree/sub/data.bin'), Buffer.from([0, 1, 128, 255])) result = await run('move source-tree renamed-tree && mkdir empty\\nested') committed = await workspaceAction('commit', context) check('persistent tree rename and empty directories commit through the native batch', result.ok && committed.writeback === 'committed' && !await fs.stat(join(workspace, 'source-tree')).catch(() => null) && (await fs.readFile(join(workspace, 'renamed-tree/sub/data.bin'))).equals(Buffer.from([0, 1, 128, 255])) && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory()) await restoreTransaction(committed.transactionId!, workspace) result = await run('if exist renamed-tree (exit /b 9) else (dir source-tree\\sub)') check('native host restore is imported as an external change into a clean retained tree', result.ok && result.data.workspace?.changes.length === 0) await assert.rejects(run('rmdir /s /q source-tree && echo changed-type>source-tree'), /文件\/目录互换/) result = await run('dir source-tree\\sub') check('unsupported private file-directory swaps are rejected before publishing a poisoned revision', result.ok && result.data.stdout.includes('data.bin')) const activeCommand = run('echo READY_FOR_STATUS && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 2"') for (let attempt = 0; attempt < 100 && !await fs.stat(join(root, 'checkout.json')).catch(() => null); attempt++) await new Promise(done => setTimeout(done, 20)) const observedAt = Date.now(), busy = await workspaceAction('status', context) check('status remains responsive while its owner is running a native command', busy.state === 'busy' && Date.now() - observedAt < 1500) await activeCommand await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'owned-runner.exe')) result = await run('owned-runner.exe --seal-workspace "%CD%"') check('sandbox code cannot invoke the host-only ACL sealing entry point', !result.ok && /host-only/.test(result.data.stderr)) await fs.unlink(join(workspace, 'owned-runner.exe')) const livePolicy = await readSandboxPolicy() const revoke = setTimeout(() => { void updateSandboxPolicy({ expectedRevision: livePolicy.revision, rules: { deniedPaths: ['file.txt'] } }) }, 1100) try { await assert.rejects(run('echo revoked>node-result.txt && powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"'), /策略已变化/) } finally { clearTimeout(revoke) } status = await workspaceAction('status', context) check('policy revocation interrupts a live draft command and leaves old checkpoint stale', status.state === 'stale' && status.changes.length === 0 && !await fs.stat(join(root, 'checkout.json')).catch(() => null)) console.log(`SANDBOX_WORKSPACE_PASS ${checks.length}/${checks.length}`) } finally { await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n') console.log(`SANDBOX_WORKSPACE_REPORT ${join(fixture, 'report.json')}`) }