148 lines
11 KiB
TypeScript
148 lines
11 KiB
TypeScript
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<boolean> {
|
|
return !!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
|
|
}
|
|
async function recoverCommit(store: PersistentWorkspace): Promise<void> {
|
|
const path = join(store.root, 'commit-intent.json')
|
|
if (!await has(path)) return
|
|
const intent = await workspaceRecord<CommitIntent>(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<SandboxWorkspaceActionResult> {
|
|
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<SandboxWorkspaceView> {
|
|
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 }
|
|
}
|
|
}
|