58 lines
6.8 KiB
TypeScript
58 lines
6.8 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 { commitChanges, restoreTransaction, digest } from '../src/main/sandbox/transactions'
|
|
|
|
if (process.platform !== 'win32') throw new Error('Directory integration requires real Windows native execution')
|
|
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
|
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-directories-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
|
await fs.mkdir(workspace)
|
|
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
|
const context = { workspaceRoot: workspace, sessionId: 'directory-session', runId: 'directory-run', toolCallId: 'directory-call', approve: async () => true }
|
|
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}`) }
|
|
try {
|
|
let approvals = 0
|
|
let result = await executeSandboxCommand('mkdir empty\\nested', { ...context, approve: async () => { approvals++; return true } })
|
|
check('empty directory tree commits after one batch approval', result.ok && approvals === 1 && result.data.changes.filter(change => change.operation === 'mkdir').length === 2 && (await fs.stat(join(workspace, 'empty/nested'))).isDirectory())
|
|
const manifest = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', result.data.transactionId!, 'manifest.json'), 'utf8'))
|
|
check('directory transaction persists a versioned recovery journal', manifest.version === 3 && manifest.directories.length === 2 && /^[a-f0-9]{16}:[a-f0-9]{32}$/.test(manifest.workspaceIdentity))
|
|
await restoreTransaction(result.data.transactionId!, workspace); await restoreTransaction(result.data.transactionId!, workspace)
|
|
check('directory creation recovery removes children and parents and is idempotent', !await fs.stat(join(workspace, 'empty')).catch(() => null))
|
|
result = await executeSandboxCommand('mkdir denied', { ...context, approve: async () => false })
|
|
check('directory creation rejection leaves no host directory', !result.ok && !await fs.stat(join(workspace, 'denied')).catch(() => null))
|
|
await fs.mkdir(join(workspace, 'original/sub'), { recursive: true }); await fs.writeFile(join(workspace, 'original/sub/data.bin'), Buffer.from([0, 1, 127, 255]))
|
|
result = await executeSandboxCommand('rmdir /s /q original', context)
|
|
check('recursive directory deletion records files and directories in one transaction', result.ok && result.data.changes.some(change => change.operation === 'delete') && result.data.changes.filter(change => change.operation === 'rmdir').length === 2 && !await fs.stat(join(workspace, 'original')).catch(() => null))
|
|
await restoreTransaction(result.data.transactionId!, workspace)
|
|
check('recursive deletion recovery recreates original directories and exact binary bytes', (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])))
|
|
result = await executeSandboxCommand('move original moved', context)
|
|
check('directory rename is represented by complete transactional tree changes', result.ok && !await fs.stat(join(workspace, 'original')).catch(() => null) && (await fs.stat(join(workspace, 'moved/sub'))).isDirectory())
|
|
await restoreTransaction(result.data.transactionId!, workspace)
|
|
check('directory rename restores its original tree and removes the new tree', !await fs.stat(join(workspace, 'moved')).catch(() => null) && (await fs.stat(join(workspace, 'original/sub'))).isDirectory())
|
|
const conflict = await executeSandboxCommand('rmdir /s /q original', { ...context, approve: async () => { await fs.writeFile(join(workspace, 'original/external.txt'), 'external'); return true } }).then(() => null, error => error as Error)
|
|
check('approval-time additions reject the full directory deletion before mutation', !!conflict && (await fs.readFile(join(workspace, 'original/sub/data.bin'))).equals(Buffer.from([0, 1, 127, 255])) && await fs.readFile(join(workspace, 'original/external.txt'), 'utf8') === 'external')
|
|
await fs.unlink(join(workspace, 'original/external.txt'))
|
|
await fs.writeFile(join(workspace, 'original/.env'), 'DUMMY_CREDENTIAL')
|
|
await assert.rejects(executeSandboxCommand('rmdir /s /q original', context), /未授权|新增内容/)
|
|
check('excluded credentials inside a deleted directory are protected by host preflight', await fs.readFile(join(workspace, 'original/.env'), 'utf8') === 'DUMMY_CREDENTIAL' && (await fs.stat(join(workspace, 'original/sub/data.bin'))).isFile())
|
|
await fs.unlink(join(workspace, 'original/.env'))
|
|
result = await executeSandboxCommand('mkdir restored-conflict & echo generated>restored-conflict\\file.txt', context)
|
|
await fs.writeFile(join(workspace, 'restored-conflict/external.txt'), 'external')
|
|
await assert.rejects(restoreTransaction(result.data.transactionId!, workspace), /未授权|新增内容/)
|
|
check('directory recovery refuses to remove later external additions', (await fs.readFile(join(workspace, 'restored-conflict/file.txt'), 'utf8')).trim() === 'generated' && await fs.readFile(join(workspace, 'restored-conflict/external.txt'), 'utf8') === 'external')
|
|
await fs.unlink(join(workspace, 'restored-conflict/external.txt')); await restoreTransaction(result.data.transactionId!, workspace)
|
|
check('recovery succeeds after the external conflict is explicitly resolved', !await fs.stat(join(workspace, 'restored-conflict')).catch(() => null))
|
|
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
|
let guarded = 0
|
|
const failed = await commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }], context, undefined,
|
|
{ beforeApply: async () => undefined, check: async () => { if (++guarded === 3) throw new Error('injected policy revocation') } }, [{ path: 'compensated-empty', before: false, after: true }]).then(() => null, error => error as Error & { recoveryRequired?: boolean })
|
|
check('mid-commit failure compensates both file writes and created directories', !!failed && !failed.recoveryRequired && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before' && !await fs.stat(join(workspace, 'compensated-empty')).catch(() => null))
|
|
console.log(`SANDBOX_DIRECTORY_PASS ${checks.length}/${checks.length}`)
|
|
} finally {
|
|
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Native NTFS transactions pin root/ancestors and use relative handle operations. Rename handoff is not a single atomic swap. ACL/timestamp parity, power-loss durability and Windows recycle-bin semantics remain pending.'] }, null, 2) + '\n')
|
|
console.log(`SANDBOX_DIRECTORY_REPORT ${join(fixture, 'report.json')}`)
|
|
}
|