import { promises as fs } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' import type { SandboxWorkspaceAction, SandboxWorkspaceActionResult, SandboxWorkspaceView } from '@shared/sandbox' import { effectiveSandboxPolicy } from './policy-store' import { verifiedBinary } from './native-component' import { NativeWorkspaceReader } from './filesystem-reader' import { PersistentWorkspace, saveWorkspaceRecord, workspaceRecord } from './workspace' import { withSandboxExecution } from './execution' import { authorizeSandboxWrite, type SandboxAuthorizationContext } from './authorizations' import { commitChanges, digest, fileChangeDigest, restoreTransaction, type SandboxCallIdentity } from './transactions' import { sandboxConfiguration } from './configuration' import { recoverSandboxGitLock, resetSandboxGit } from './git-repository' import { within } from './policy' import { hostGitRoot, loadHostGitBinding } from './git-host-source' import { gitHostAction } from './git-host-service' export interface WorkspaceContext extends SandboxCallIdentity, SandboxAuthorizationContext { workspaceRoot: string; sessionId: string } interface CommitIntent { version: 1; operationId: string; revision: string; authorizationId: string; policyFingerprint: string; createdAt: number; transactionId?: string } const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ async function has(path: string): Promise { return !!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error }) } async function recoverCommit(store: PersistentWorkspace): Promise { const path = join(store.root, 'commit-intent.json') if (!await has(path)) return const intent = await workspaceRecord(path, 4096) if (intent.version !== 1 || !uuid.test(intent.operationId) || !uuid.test(intent.authorizationId) || !uuid.test(intent.revision) || !store.revision || intent.policyFingerprint !== store.policy.fingerprint || intent.revision !== store.revision.id && intent.revision !== store.revision.previousRevision || intent.transactionId !== undefined && !uuid.test(intent.transactionId)) throw new Error('长期工作区宿主提交日志无效或策略已失效') let transaction: { id: string; status: string } | undefined const root = join(sandboxConfiguration().stateRoot, 'transactions') const records = await fs.readdir(root, { withFileTypes: true }).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }) if (records.length > 10000) throw new Error('宿主事务历史过多,请先通过维护入口核对提交结果') for (const entry of records) { if (!entry.isDirectory() || entry.isSymbolicLink() || !uuid.test(entry.name)) throw new Error('宿主事务历史目录无效') if (intent.transactionId && entry.name !== intent.transactionId) continue const file = join(root, entry.name, 'manifest.json') if (!await has(file)) continue // A transaction without its prepared manifest cannot have applied host writes. const value = await workspaceRecord<{ id: string; workspaceRoot: string; status: string; identity: { authorizationId?: string; sessionId?: string } }>(file) if (value.identity?.authorizationId !== intent.authorizationId) continue if (value.id !== entry.name || value.workspaceRoot !== store.policy.workspaceRoot || value.identity.sessionId !== store.sessionId || transaction) throw new Error('宿主提交事务归属不唯一或不匹配') transaction = { id: value.id, status: value.status } } if (intent.transactionId && transaction?.id !== intent.transactionId) throw new Error('无法确定已记录宿主事务的结果') if (transaction?.status === 'committed') { if (store.revision.lastTransactionId !== transaction.id) await store.committed(transaction.id) } else if (transaction && transaction.status !== 'rolled-back') { await restoreTransaction(transaction.id, store.policy.workspaceRoot) } await saveWorkspaceRecord(join(store.root, 'actions', `${intent.operationId}.json`), { ...intent, state: 'recovered', writeback: transaction?.status === 'committed' ? 'committed' : 'not-applied', transactionId: transaction?.id, completedAt: Date.now() }) await fs.unlink(path); store.interrupted = false } export async function workspaceAction(action: SandboxWorkspaceAction, context: WorkspaceContext, expectedRevision?: string): Promise { if (!['status', 'changes', 'commit', 'discard', 'recover'].includes(action)) throw new Error('长期工作区操作无效') if (expectedRevision !== undefined && !uuid.test(expectedRevision)) throw new Error('长期工作区预期版本无效') if (['commit', 'discard', 'recover'].includes(action)) { const policy = await effectiveSandboxPolicy(context.workspaceRoot), binding = await loadHostGitBinding(policy, context.sessionId) const pendingGit = !!await fs.lstat(join(hostGitRoot(policy.workspaceRoot, context.sessionId), 'clone.json')).catch(() => null) if (binding || pendingGit) { const git = await gitHostAction(action as 'commit' | 'discard' | 'recover', context, undefined, expectedRevision) return { ...(git.workspace ?? await workspaceAction('status', context)), action, ...(git.writeback ? { writeback: git.writeback } : {}), transactionId: git.transactionId, operationId: git.groupId, reason: git.reason, git } } } if (action === 'status') { const root = await fs.realpath(context.workspaceRoot), policy = await effectiveSandboxPolicy(root) if (within(sandboxConfiguration().stateRoot, root)) throw new Error('沙盒状态目录不能作为长期工作区的宿主项目') const identity = (await NativeWorkspaceReader.create(await verifiedBinary(true, true), policy, context.signal)).rootIdentity const view = await PersistentWorkspace.peek(policy, context.sessionId, identity) if (expectedRevision !== undefined && view.revision !== expectedRevision) throw new Error('长期工作区版本已变化,请刷新后重试') return { ...view, action } } return withSandboxExecution(context.workspaceRoot, context.signal, async root => { if (within(sandboxConfiguration().stateRoot, root)) throw new Error('沙盒状态目录不能作为长期工作区的宿主项目') const ctx = { ...context, workspaceRoot: root }, policy = await effectiveSandboxPolicy(root) const rootIdentity = (await NativeWorkspaceReader.create(await verifiedBinary(true, true), policy, ctx.signal)).rootIdentity const store = await PersistentWorkspace.open(policy, ctx.sessionId, rootIdentity, action === 'recover' || action === 'discard') try { if (expectedRevision !== undefined && store.revision?.id !== expectedRevision) throw new Error('长期工作区版本已变化,请刷新后重试') if (action === 'discard') { if (await has(join(store.root, 'commit-intent.json'))) throw new Error('有宿主提交结果尚未确定,请先 recover') await recoverSandboxGitLock(root, store.namespace) await store.discard() await resetSandboxGit(root, store.namespace) return { ...store.view(), action, writeback: 'not-applied' } } store.assertUsable() if (action === 'recover') { await store.assertCurrent(ctx.signal) if (!store.revision) { await store.initialize(); return { ...store.view(), action } } await recoverCommit(store) await store.recoverCheckout() await recoverSandboxGitLock(root, store.namespace) return { ...store.view(), action } } try { await store.synchronize() } catch (error) { if (store.conflicts.length) return { ...store.view(), action, reason: (error as Error).message } throw error } if (action === 'changes') return { ...store.view(), action } const changes = await store.fileChanges() if (!changes.files.length && !changes.directories.length) return { ...store.view(), action, writeback: 'unchanged' } if (changes.files.length + changes.directories.length > policy.maxChanges) throw new Error('草稿变更超过当前策略的批次上限') const revision = store.revision!.id const targetDigest = digest(Buffer.from(JSON.stringify({ revision, rootIdentity, files: fileChangeDigest(changes.files, changes.directories) }))) const description = `将长期沙盒草稿的 ${store.view().changes.length} 项变更保存到宿主项目?\n${store.view().changes.slice(0, 30).map(change => `${change.operation}: ${change.path}`).join('\n')}` const authorization = await authorizeSandboxWrite(policy, description, ctx, targetDigest) if (!authorization) return { ...store.view(), action, writeback: 'rejected' } await store.assertCurrent(ctx.signal) const intent: CommitIntent = { version: 1, operationId: randomUUID(), revision, authorizationId: authorization.id, policyFingerprint: policy.fingerprint, createdAt: Date.now() } const intentPath = join(store.root, 'commit-intent.json'), outcomePath = join(store.root, 'actions', `${intent.operationId}.json`) await saveWorkspaceRecord(intentPath, intent, false) store.interrupted = true let committed = false, transactionId: string | undefined try { transactionId = await commitChanges(root, changes.files, { ...ctx, policyRevision: policy.revision, policyFingerprint: policy.fingerprint, authorizationId: authorization.id }, ctx.signal, { workspaceIdentity: rootIdentity, beforeApply: async () => { await store.assertCurrent(ctx.signal); await authorization.consume(targetDigest) }, check: () => authorization.assertValid(targetDigest) }, changes.directories) committed = true await saveWorkspaceRecord(outcomePath, { ...intent, state: 'completed', writeback: 'committed', transactionId, completedAt: Date.now() }) await store.committed(transactionId) await fs.unlink(intentPath); store.interrupted = false return { ...store.view(), action, writeback: 'committed', transactionId, authorizationId: authorization.id, operationId: intent.operationId } } catch (error) { const failure = error as Error & { transactionId?: string; recoveryRequired?: boolean } if (!committed && !failure.recoveryRequired) { await fs.unlink(intentPath); store.interrupted = false } else await saveWorkspaceRecord(intentPath, { ...intent, transactionId: transactionId ?? failure.transactionId }).catch(() => undefined) await saveWorkspaceRecord(outcomePath, { ...intent, state: 'failed', writeback: committed ? 'committed' : failure.recoveryRequired ? 'unknown' : 'not-applied', transactionId: transactionId ?? failure.transactionId, error: failure.message, completedAt: Date.now() }).catch(() => undefined) return { ...store.view(), action, reason: failure.message, writeback: committed ? 'committed' : failure.recoveryRequired ? 'unknown' : 'not-applied', transactionId: transactionId ?? failure.transactionId, authorizationId: authorization.id, operationId: intent.operationId } } } finally { await store.close() } }) } export async function sandboxWorkspaceStatus(workspaceRoot: string, sessionId: string): Promise { try { return await workspaceAction('status', { workspaceRoot, sessionId }) } catch (error) { const reason = error instanceof Error ? error.message : String(error) return { mode: 'persistent', state: /正在使用|活跃主进程/.test(reason) ? 'busy' : 'invalid', sessionId, changes: [], reason } } }