Files
ycode/scripts/sandbox-workspace-regression.ts
T

226 lines
23 KiB
TypeScript

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<Parameters<typeof executeSandboxCommand>[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<WorkspaceRevision> {
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<void>((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')}`)
}