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 { NativeWorkspaceWriter } from '../src/main/sandbox/filesystem-writer' import { digest } from '../src/main/sandbox/transactions' import { commitChanges, restoreTransaction } from '../src/main/sandbox/transactions' import { executeSandboxCommand } from '../src/main/sandbox/service' import { executeTool } from '../src/main/agent/tools' import { execFileSync, type ChildProcess } from 'node:child_process' import { randomUUID } from 'node:crypto' if (process.platform !== 'win32') throw new Error('Native write integration requires Windows') await fs.mkdir(resolve('.tmp'), { recursive: true }) const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-write-')) const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state') await fs.mkdir(workspace) configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') }) 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 { await fs.writeFile(join(workspace, 'sample.txt'), 'before') const writer = await NativeWorkspaceWriter.create(workspace) try { const before = await writer.inspect('sample.txt', true) check('native transaction reads exact bytes under a pinned root', before.bytes?.toString() === 'before') await writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('after')) check('replacement succeeds while original target remains pinned', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after') await assert.rejects(writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('wrong')), /changed/) check('stale expected content cannot overwrite the target', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after') } finally { await writer.close() } await fs.mkdir(join(workspace, 'nested')) await fs.writeFile(join(workspace, 'nested/data.bin'), Buffer.from([0, 255, 1, 127])) const outside = join(fixture, 'outside'); await fs.mkdir(outside) await fs.writeFile(join(outside, 'data.bin'), 'OUTSIDE_UNCHANGED') const pinned = await NativeWorkspaceWriter.create(workspace) try { await pinned.inspect('nested/data.bin', true) const moved = await fs.rename(join(workspace, 'nested'), join(workspace, 'moved')).then(() => true, () => false) check('pinned ancestor cannot be renamed into a junction attack', !moved) const rootMoved = await fs.rename(workspace, join(fixture, 'replaced-root')).then(() => true, () => false) check('workspace root remains pinned for the whole transaction session', !rootMoved) await pinned.replace('nested/data.bin', digest(Buffer.from([0, 255, 1, 127])), Buffer.alloc(0)) check('nested binary-to-empty replacement preserves byte semantics', (await fs.stat(join(workspace, 'nested/data.bin'))).size === 0) await pinned.replace('created.bin', null, Buffer.from([0, 128, 255])) await assert.rejects(pinned.replace('created.bin', null, Buffer.from('overwrite')), /changed/) check('creation refuses to overwrite a target that already exists', (await fs.readFile(join(workspace, 'created.bin'))).equals(Buffer.from([0, 128, 255]))) for (const path of ['../outside/data.bin', '.env', 'stream.txt:ads', '.ycode-sandbox-owned.tmp']) await assert.rejects(pinned.replace(path, null, Buffer.from('blocked'))) check('outside, sensitive, ADS and reserved transaction paths are denied', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED') await fs.symlink(outside, join(workspace, 'junction'), 'junction') await assert.rejects(pinned.replace('junction/data.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked'))) check('native relative opens reject a junction target', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED') await fs.unlink(join(workspace, 'junction')) await fs.link(join(outside, 'data.bin'), join(workspace, 'hardlink.bin')) await assert.rejects(pinned.replace('hardlink.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')), /hard link/) check('native replacement rejects hard-linked files', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED') await fs.unlink(join(workspace, 'hardlink.bin')) const busy = await fs.open(join(workspace, 'sample.txt'), 'r+') try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /win32=32/) } finally { await busy.close() } check('existing writable handles fail closed without relaxed sharing', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after') await fs.chmod(join(workspace, 'sample.txt'), 0o444) try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /read-only/) } finally { await fs.chmod(join(workspace, 'sample.txt'), 0o666) } check('read-only targets are rejected before handoff', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after') await pinned.replace('created.bin', digest(Buffer.from([0, 128, 255])), null) check('native deletion removes only the verified file object', !await fs.stat(join(workspace, 'created.bin')).catch(() => null)) } finally { await pinned.close() } await fs.rename(join(workspace, 'nested'), join(workspace, 'released')) check('session close releases ancestor pins', (await fs.stat(join(workspace, 'released'))).isDirectory()) const context = { workspaceRoot: workspace, sessionId: 'write-session', runId: 'write-run', toolCallId: 'write-call', approve: async () => true } await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'runner-copy.exe')) const hostOnly = await executeSandboxCommand('runner-copy.exe --workspace-write "%CD%" -', context) check('sandbox descendants cannot invoke the host transaction entry point', !hostOnly.ok && /host-only/.test(hostOnly.data.stderr)) await fs.unlink(join(workspace, 'runner-copy.exe')) const tool = await executeTool('write_file', { path: 'implicit/a/b/file.txt', content: 'implicit-content' }, context) const transactionId = (tool.data as { transactionId?: string })?.transactionId check('default file tool commits through the native transaction backend', tool.ok && !!transactionId && await fs.readFile(join(workspace, 'implicit/a/b/file.txt'), 'utf8') === 'implicit-content') const journal = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId!, 'manifest.json'), 'utf8')) check('journal binds root identity and records implicit parents and mutation intent', journal.version === 3 && journal.workspaceIdentity && journal.directories.length === 3 && journal.mutations.length === 1) await restoreTransaction(transactionId!, workspace) check('recovery removes implicitly created parents as well as file bytes', !await fs.stat(join(workspace, 'implicit')).catch(() => null)) let attempts = 0 const guardedId = await commitChanges(workspace, [{ path: 'released/data.bin', beforeHash: digest(Buffer.alloc(0)), after: Buffer.from('guarded') }], context, undefined, { beforeApply: async () => { for (let i = 0; i < 20; i++) if (!await fs.rename(join(workspace, 'released'), join(workspace, 'attack-moved')).then(() => true, () => false)) attempts++ }, check: async () => undefined }) check('real commit rejects repeated ancestor replacement attempts after preflight', attempts === 20 && await fs.readFile(join(workspace, 'released/data.bin'), 'utf8') === 'guarded' && await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED') await restoreTransaction(guardedId, workspace) await fs.mkdir(join(workspace, 'delete-tree')); await fs.writeFile(join(workspace, 'delete-tree/original.txt'), 'original') await assert.rejects(commitChanges(workspace, [{ path: 'delete-tree/original.txt', beforeHash: digest(Buffer.from('original')), after: null }], context, undefined, { beforeApply: async () => { await fs.writeFile(join(workspace, 'delete-tree/external.txt'), 'external') }, check: async () => undefined }, [{ path: 'delete-tree', before: true, after: false }])) check('late directory additions stop deletion and compensate earlier file removals', await fs.readFile(join(workspace, 'delete-tree/original.txt'), 'utf8') === 'original' && await fs.readFile(join(workspace, 'delete-tree/external.txt'), 'utf8') === 'external') await assert.rejects(commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('after')), after: Buffer.from('approved') }], context, undefined, { beforeApply: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-edit') }, check: async () => undefined })) check('post-preflight external file edits survive a rejected commit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-edit') for (const route of ['command', 'file-tool']) { const targetRoot = join(fixture, `approval-${route}`), movedRoot = `${targetRoot}-old` await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval') const approve = async (): Promise => { await fs.rename(targetRoot, movedRoot); await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval'); return true } if (route === 'command') await assert.rejects(executeSandboxCommand('echo approved>sample.txt', { ...context, workspaceRoot: targetRoot, approve }), /identity changed|被替换/) else assert.equal((await executeTool('write_file', { path: 'sample.txt', content: 'approved' }, { ...context, workspaceRoot: targetRoot, approve })).ok, false) check(`${route} approval cannot authorize a replacement root with identical contents`, await fs.readFile(join(targetRoot, 'sample.txt'), 'utf8') === 'before-approval' && await fs.readFile(join(movedRoot, 'sample.txt'), 'utf8') === 'before-approval') } const rootRecovery = join(fixture, 'root-recovery'); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'before') const rootTransaction = await commitChanges(rootRecovery, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }]) await fs.rename(rootRecovery, `${rootRecovery}-old`); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'after') await assert.rejects(restoreTransaction(rootTransaction, rootRecovery), /identity changed/) check('recovery refuses a same-path replacement root with matching file hashes', await fs.readFile(join(rootRecovery, 'sample.txt'), 'utf8') === 'after') await fs.writeFile(join(workspace, 'first.txt'), 'first-before'); await fs.writeFile(join(workspace, 'second.txt'), 'second-before') let phase = 0 const killed = await commitChanges(workspace, [ { path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('first-after') }, { path: 'second.txt', beforeHash: digest(Buffer.from('second-before')), after: Buffer.from('second-after') } ], context, undefined, { beforeApply: async () => undefined, check: async () => { if (++phase !== 2) return const query = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${process.pid}' | Where-Object { $_.Name -eq 'ycode-sandbox.exe' } | Select-Object -ExpandProperty ProcessId` const pids = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { encoding: 'utf8', windowsHide: true }).trim().split(/\s+/).map(Number).filter(Number.isSafeInteger) assert.equal(pids.length, 1); process.kill(pids[0]); await new Promise(done => setTimeout(done, 40)) } }).then(() => null, error => error as Error & { transactionId?: string; recoveryRequired?: boolean }) check('native backend death reports recovery-required after a partial batch', killed?.recoveryRequired && !!killed.transactionId && await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-after') await restoreTransaction(killed!.transactionId!, workspace) check('a new native session recovers a batch after actual backend death', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && await fs.readFile(join(workspace, 'second.txt'), 'utf8') === 'second-before') const handoff = await commitChanges(workspace, [{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('handoff-after') }]) const manifestPath = join(stateRoot, 'transactions', handoff, 'manifest.json'), manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) const intent = manifest.mutations[0], oldScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-old.tmp`), newScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-new.tmp`) await fs.unlink(join(workspace, 'first.txt')); await fs.writeFile(oldScratch, 'first-before'); await fs.writeFile(newScratch, 'handoff-after') manifest.status = 'applying'; manifest.mutations = []; await fs.writeFile(manifestPath, JSON.stringify(manifest)) await restoreTransaction(handoff, workspace) check('interrupted-handoff disk fixture restores absent basename and cleans verified scratch', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && !await fs.stat(oldScratch).catch(() => null) && !await fs.stat(newScratch).catch(() => null)) const blocked = await NativeWorkspaceWriter.create(workspace) try { const nonce = randomUUID().replaceAll('-', ''), scratch = join(workspace, `.ycode-sandbox-${nonce}-old.tmp`) await fs.writeFile(scratch, 'foreign-content') await assert.rejects(blocked.cleanup('first.txt', nonce, digest(Buffer.from('first-before')), null), /changed externally/) check('scratch cleanup refuses unrelated or externally changed contents', await fs.readFile(scratch, 'utf8') === 'foreign-content') } finally { await blocked.close() } const dying = await NativeWorkspaceWriter.create(workspace) const child = Reflect.get(dying, 'child') as ChildProcess child.kill(); await new Promise(done => setTimeout(done, 30)) await assert.rejects(dying.replace('first.txt', digest(Buffer.from('first-before')), Buffer.from('never'))) await dying.close() check('dead native channel cannot fall back to an ordinary host write', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before') const cliRoot = join(fixture, 'CLI work \u4e2d\u6587 ^'); await fs.mkdir(cliRoot); await fs.writeFile(join(cliRoot, 'sample.txt'), 'cli-before') const cliId = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('cli-after') }]) execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'restore', cliId, '--workspace', cliRoot, '--state-root', stateRoot], { windowsHide: true, timeout: 30_000, stdio: 'pipe' }) check('actual maintenance CLI restores v3 at a space Unicode caret path', await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before') for (const version of [1, 2]) { const id = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('legacy-after') }]) const path = join(stateRoot, 'transactions', id, 'manifest.json'), legacy = JSON.parse(await fs.readFile(path, 'utf8')) legacy.version = version; delete legacy.workspaceIdentity; delete legacy.mutationJournal; delete legacy.mutations if (version === 1) delete legacy.directories await fs.writeFile(path, JSON.stringify(legacy)) await restoreTransaction(id, cliRoot) const upgraded = JSON.parse(await fs.readFile(path, 'utf8')) check(`legacy v${version} recovery uses native backend and upgrades root identity`, await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before' && upgraded.version === 3 && !!upgraded.workspaceIdentity) } console.log(`SANDBOX_WRITE_PASS ${checks.length}/${checks.length}`) } finally { await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n') console.log(`SANDBOX_WRITE_REPORT ${join(fixture, 'report.json')}`) }