fix(sandbox): ignore generated output and clean failed drafts
This commit is contained in:
1 parent
d5512df549
commit
3be45999f7
3 files changed
+17
-7
No files matched your search
@@ -27,6 +27,10 @@ if (process.platform !== 'win32') throw new Error('Persistent workspace requires
|
|||||||
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
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')
|
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')
|
await fs.mkdir(workspace); await fs.writeFile(join(workspace, 'file.txt'), 'host-before')
|
||||||
|
for (const generated of ['out', 'dist', 'release']) {
|
||||||
|
await fs.mkdir(join(workspace, generated), { recursive: true })
|
||||||
|
await fs.writeFile(join(workspace, generated, 'generated.bin'), 'generated-output')
|
||||||
|
}
|
||||||
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
||||||
let approvals = 0
|
let approvals = 0
|
||||||
const context = { workspaceRoot: workspace, sessionId: 'persistent-session', runId: 'persistent-run', toolCallId: 'persistent-call', approve: async () => { approvals++; return true } }
|
const context = { workspaceRoot: workspace, sessionId: 'persistent-session', runId: 'persistent-run', toolCallId: 'persistent-call', approve: async () => { approvals++; return true } }
|
||||||
@@ -42,6 +46,7 @@ try {
|
|||||||
let result = await run('echo draft-value>file.txt')
|
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')
|
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()
|
const first = await revision()
|
||||||
|
check('generated build output stays outside the retained draft', !first.tree.some(entry => /^(out|dist|release)(\/|$)/.test(entry.path)) && first.excluded >= 3)
|
||||||
result = await run('type file.txt')
|
result = await run('type file.txt')
|
||||||
const second = await revision()
|
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('next real native command reads the retained draft', result.ok && result.data.stdout.includes('draft-value') && result.data.workspace?.changes.length === 1)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { assertActive, resolveSandboxPath, within } from './policy'
|
|||||||
import { sandboxPathDenied } from './policy-store'
|
import { sandboxPathDenied } from './policy-store'
|
||||||
|
|
||||||
export type Snapshot = Map<string, { hash: string; size: number }>
|
export type Snapshot = Map<string, { hash: string; size: number }>
|
||||||
const excludedDirectories = new Set(['.git', '.ycode', '.tmp', 'node_modules', '.venv', 'venv', '__pycache__'])
|
const excludedDirectories = new Set(['.git', '.ycode', '.tmp', 'node_modules', '.venv', 'venv', '__pycache__', 'out', 'dist', 'release'])
|
||||||
export async function regularBytes(file: string, fileLimit: number): Promise<Buffer> {
|
export async function regularBytes(file: string, fileLimit: number): Promise<Buffer> {
|
||||||
const before = await fs.lstat(file)
|
const before = await fs.lstat(file)
|
||||||
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > fileLimit) throw new Error('快照包含链接、特殊文件或超过策略上限的文件')
|
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > fileLimit) throw new Error('快照包含链接、特殊文件或超过策略上限的文件')
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const same = (a?: WorkspaceEntry, b?: WorkspaceEntry): boolean => a?.kind === b?
|
|||||||
const order = (tree: WorkspaceEntry[]): WorkspaceEntry[] => tree.sort((a, b) => a.path.localeCompare(b.path, 'en'))
|
const order = (tree: WorkspaceEntry[]): WorkspaceEntry[] => tree.sort((a, b) => a.path.localeCompare(b.path, 'en'))
|
||||||
const index = (tree: WorkspaceEntry[]): Map<string, WorkspaceEntry> => new Map(tree.map(entry => [key(entry.path), entry]))
|
const index = (tree: WorkspaceEntry[]): Map<string, WorkspaceEntry> => new Map(tree.map(entry => [key(entry.path), entry]))
|
||||||
const depth = (path: string): number => path.split('/').length
|
const depth = (path: string): number => path.split('/').length
|
||||||
const excluded = new Set(['node_modules', '.tmp', '.venv', 'venv', '__pycache__'])
|
const excluded = new Set(['node_modules', '.tmp', '.venv', 'venv', '__pycache__', 'out', 'dist', 'release'])
|
||||||
|
|
||||||
export function workspaceKey(workspace: string, sessionId: string): string {
|
export function workspaceKey(workspace: string, sessionId: string): string {
|
||||||
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200 || /[\x00-\x1f]/.test(sessionId)) throw new Error('长期工作区需要有效的宿主会话身份')
|
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200 || /[\x00-\x1f]/.test(sessionId)) throw new Error('长期工作区需要有效的宿主会话身份')
|
||||||
@@ -240,11 +240,16 @@ export class PersistentWorkspace {
|
|||||||
if (this.revision) { validateTree(this.revision.base, this.policy); validateTree(this.revision.tree, this.policy); return }
|
if (this.revision) { validateTree(this.revision.base, this.policy); validateTree(this.revision.tree, this.policy); return }
|
||||||
if (await exists(this.working)) throw new Error('长期工作区有未归属的物理目录,请先 discard')
|
if (await exists(this.working)) throw new Error('长期工作区有未归属的物理目录,请先 discard')
|
||||||
await fs.mkdir(this.working)
|
await fs.mkdir(this.working)
|
||||||
const initial = await snapshot(this.policy.workspaceRoot, this.working, this.policy)
|
try {
|
||||||
const tree = await this.capture(this.working), physicalIdentity = await sealWorkspace(this.working)
|
const initial = await snapshot(this.policy.workspaceRoot, this.working, this.policy)
|
||||||
await this.assertCurrent()
|
const tree = await this.capture(this.working), physicalIdentity = await sealWorkspace(this.working)
|
||||||
await this.publish({ version: 1, workspaceRoot: this.policy.workspaceRoot, workspaceIdentity: this.rootIdentity, sessionId: this.sessionId,
|
await this.assertCurrent()
|
||||||
policyFingerprint: this.policy.fingerprint, policyRevision: this.policy.revision, base: tree, tree, physicalIdentity, excluded: initial.excluded })
|
await this.publish({ version: 1, workspaceRoot: this.policy.workspaceRoot, workspaceIdentity: this.rootIdentity, sessionId: this.sessionId,
|
||||||
|
policyFingerprint: this.policy.fingerprint, policyRevision: this.policy.revision, base: tree, tree, physicalIdentity, excluded: initial.excluded })
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(this.working, { recursive: true, force: true })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async verifyPhysical(): Promise<void> {
|
async verifyPhysical(): Promise<void> {
|
||||||
if (!this.revision) throw new Error('长期工作区尚未初始化')
|
if (!this.revision) throw new Error('长期工作区尚未初始化')
|
||||||
|
|||||||
Reference in new issue
Block a user