feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto'
import type { SandboxEffectivePolicy } from '@shared/sandbox'
import { assertActive } from './policy'
import { assertPolicyCurrent } from './policy-store'
export interface SandboxAuthorizationContext {
runId?: string; toolCallId?: string; sessionId?: string; signal?: AbortSignal
workspaceRoot?: string
approve?: (description: string, signal?: AbortSignal) => Promise<boolean>
}
export interface SandboxAuthorization { id: string; policyRevision: string; expiresAt: number; assertValid(targetDigest: string): Promise<void>; consume(targetDigest: string): Promise<void> }
let pending = 0
const duration = 10 * 60_000
/** Tickets remain in the trusted process; a child-supplied ID never grants access. */
export async function authorizeSandboxWrite(policy: SandboxEffectivePolicy, description: string, context: SandboxAuthorizationContext, targetDigest: string, writebackRequired = true): Promise<SandboxAuthorization | null> {
if (!/^[a-f0-9]{64}$/.test(targetDigest)) throw new Error('沙盒操作摘要无效')
assertActive(context.signal)
await assertPolicyCurrent(policy)
if (writebackRequired && policy.writeback === 'deny') throw new Error('沙盒策略禁止写回工作区')
if (pending >= 64) throw new Error('沙盒待审批请求过多')
const expiresAt = Date.now() + duration
const started = performance.now()
const expired = (): boolean => Date.now() >= expiresAt || performance.now() - started >= duration
const identity = { runId: context.runId, toolCallId: context.toolCallId, sessionId: context.sessionId, workspaceRoot: context.workspaceRoot }
const sameOwner = (): boolean => context.runId === identity.runId && context.toolCallId === identity.toolCallId && context.sessionId === identity.sessionId && context.workspaceRoot === identity.workspaceRoot
pending++
let approved = false
const controller = new AbortController()
const onAbort = (): void => controller.abort()
context.signal?.addEventListener('abort', onAbort, { once: true })
if (context.signal?.aborted) controller.abort()
let revoked: Error | undefined, checking = false
const revoke = (error: Error): void => { revoked ??= error; controller.abort() }
const deadline = setTimeout(() => revoke(new Error('沙盒审批已过期,请重新发起操作')), duration)
const watch = setInterval(() => {
if (checking) return
checking = true
void assertPolicyCurrent(policy).catch(error => revoke(error instanceof Error ? error : new Error(String(error)))).finally(() => { checking = false })
}, 250)
let detach = (): void => undefined
const interrupted = new Promise<boolean>((_done, reject) => {
const onInterrupted = (): void => reject(revoked ?? Object.assign(new Error('沙盒审批已取消'), { name: 'AbortError' }))
controller.signal.addEventListener('abort', onInterrupted, { once: true })
detach = () => controller.signal.removeEventListener('abort', onInterrupted)
if (controller.signal.aborted) onInterrupted()
})
const decision = Promise.resolve().then(() => {
if (controller.signal.aborted) throw revoked ?? Object.assign(new Error('沙盒审批已取消'), { name: 'AbortError' })
return context.approve?.(description, controller.signal) ?? false
})
try { approved = await Promise.race([decision, interrupted]) === true }
catch (error) { throw revoked ?? error }
finally { detach(); clearTimeout(deadline); clearInterval(watch); context.signal?.removeEventListener('abort', onAbort); pending-- }
assertActive(context.signal)
await assertPolicyCurrent(policy)
if (expired()) throw new Error('沙盒审批已过期,请重新发起操作')
if (!sameOwner()) throw new Error('沙盒审批期间调用归属发生变化')
if (!approved) return null
let consumed = false, invalidated = false
const assertValid = async (actualDigest: string): Promise<void> => {
assertActive(context.signal)
if (actualDigest !== targetDigest) throw new Error('沙盒授权目标或内容发生变化')
if (invalidated || expired() || !sameOwner()) { invalidated = true; throw new Error('沙盒授权已过期或调用归属发生变化') }
try { await assertPolicyCurrent(policy) } catch (error) { invalidated = true; throw error }
}
return { id: randomUUID(), policyRevision: policy.revision, expiresAt, assertValid,
async consume(actualDigest) { await assertValid(actualDigest); if (consumed) throw new Error('沙盒授权已使用'); consumed = true } }
}
+13
View File
@@ -0,0 +1,13 @@
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
export interface SandboxConfiguration { stateRoot: string; resourceDirectory: string; useBundledGit?: boolean }
let configuration: SandboxConfiguration = {
stateRoot: join(tmpdir(), `YCode-sandbox-${process.pid}`),
resourceDirectory: resolve('resources/sandbox/win32-x64')
}
/** Main injects userData/resources once; pure tool tests keep their state in a process-owned temporary location. */
export function configureSandbox(value: SandboxConfiguration): void {
configuration = { stateRoot: resolve(value.stateRoot), resourceDirectory: resolve(value.resourceDirectory), useBundledGit: value.useBundledGit !== false }
}
export function sandboxConfiguration(): Readonly<SandboxConfiguration> { return configuration }
+145
View File
@@ -0,0 +1,145 @@
import { promises as fs } from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import { dirname, join, relative, resolve, isAbsolute } from 'node:path'
import type { SandboxDependencyIdentity, SandboxDependencyKind, SandboxToolchainIdentity } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { assertActive, protectedPath, resolveSandboxPath, within } from './policy'
import { effectiveSandboxPolicy, sandboxPathDenied } from './policy-store'
import { sandboxToolchainIdentity } from './toolchains'
interface DependencyFile { path: string; bytes: number; sha256: string }
interface DependencyManifest { version: 1; workspaceRoot: string; identity: SandboxDependencyIdentity; files: DependencyFile[]; bytes: number; excluded: number }
const hash = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex')
const rootKey = (root: string): string => hash(process.platform === 'win32' ? root.toLowerCase() : root)
const limits = { files: 30_000, bytes: 512 * 1024 * 1024, fileBytes: 32 * 1024 * 1024, manifestBytes: 8 * 1024 * 1024 }
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
function kindValue(kind: string): SandboxDependencyKind {
if (kind !== 'node' && kind !== 'python') throw new Error('依赖缓存只支持 node/python')
return kind
}
function validPath(path: string): boolean { return !!path && !isAbsolute(path) && !path.split(/[\\/]/).some(part => !part || part === '.' || part === '..') && !protectedPath(path) }
async function regular(path: string, maximum = limits.fileBytes): Promise<Buffer> {
const stat = await fs.lstat(path)
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error('依赖文件是链接、特殊文件或超过上限')
const handle = await fs.open(path, 'r')
try {
const opened = await handle.stat()
if (opened.ino !== stat.ino || opened.dev !== stat.dev || opened.nlink !== 1 || opened.size !== stat.size) throw new Error('依赖文件读取前发生替换')
const bytes = await handle.readFile(), after = await handle.stat()
if (bytes.length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error('依赖文件读取期间发生变化')
return bytes
} finally { await handle.close() }
}
async function directory(path: string): Promise<void> {
const stat = await fs.lstat(path)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('依赖缓存目录不能是链接')
}
export async function dependencyLockHash(workspace: string, kind: SandboxDependencyKind): Promise<string> {
kindValue(kind)
const names = kind === 'node' ? ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock']
: ['pyproject.toml', 'requirements.txt', 'requirements.lock', 'poetry.lock', 'uv.lock']
const values = []
for (const name of names) {
const path = await resolveSandboxPath(workspace, name)
const exists = await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
values.push([name, exists ? hash(await regular(path, 8 * 1024 * 1024)) : null])
}
return hash(JSON.stringify(values))
}
function cacheRoot(workspace: string): string { return join(sandboxConfiguration().stateRoot, 'dependencies', rootKey(workspace)) }
/** Only explicit workspace-local dependency roots are imported; no arbitrary model-supplied path is opened. */
export async function importSandboxDependencies(workspaceInput: string, kindInput: string): Promise<{ identity: SandboxDependencyIdentity; files: number; bytes: number; excluded: number }> {
const kind = kindValue(kindInput), workspace = await fs.realpath(workspaceInput)
const policy = await effectiveSandboxPolicy(workspace)
const source = await resolveSandboxPath(workspace, kind === 'node' ? 'node_modules' : '.venv/Lib/site-packages')
if (sandboxPathDenied(policy, source)) throw new Error('沙盒策略禁止导入该依赖路径')
if (within(source, resolve(sandboxConfiguration().stateRoot))) throw new Error('依赖源目录不能包含沙盒状态目录')
await directory(source)
const runtime = await sandboxToolchainIdentity(kind)
if (!runtime) throw new Error('请先注册对应运行时再导入依赖')
const id = randomUUID(), parent = cacheRoot(workspace), object = join(parent, 'objects', id), target = join(object, 'files')
await fs.mkdir(target, { recursive: true, mode: 0o700 })
for (const path of [join(sandboxConfiguration().stateRoot, 'dependencies'), parent, join(parent, 'objects'), object, target]) await directory(path)
const identity: SandboxDependencyIdentity = { kind, id, lockHash: await dependencyLockHash(workspace, kind), manifestSha256: '', runtimeId: runtime.id, runtimeManifestSha256: runtime.manifestSha256 }
const manifest: DependencyManifest = { version: 1, workspaceRoot: workspace, identity, files: [], bytes: 0, excluded: 0 }
async function walk(path: string, depth = 0): Promise<void> {
if (depth > 64) throw new Error('依赖目录层级过深')
await directory(path)
for await (const entry of await fs.opendir(path)) {
if (entry.name === '__pycache__') { manifest.excluded++; continue }
const absolute = join(path, entry.name), file = relative(source, absolute).replaceAll('\\', '/')
if (protectedPath(file)) { manifest.excluded++; continue }
if (sandboxPathDenied(policy, absolute)) throw new Error('依赖文件被工作区策略禁止')
await resolveSandboxPath(workspace, absolute)
if (entry.isSymbolicLink()) throw new Error('依赖缓存不接受链接;请先生成实际文件布局')
if (entry.isDirectory()) await walk(absolute, depth + 1)
else {
if (!validPath(file) || manifest.files.length >= limits.files) throw new Error('依赖路径或总项数无效')
const bytes = await regular(absolute)
manifest.bytes += bytes.length
if (manifest.bytes > limits.bytes) throw new Error('依赖超过 512 MiB 缓存上限')
await fs.mkdir(dirname(join(target, file)), { recursive: true })
await fs.writeFile(join(target, file), bytes, { flag: 'wx', mode: 0o600 })
manifest.files.push({ path: file, bytes: bytes.length, sha256: hash(bytes) })
}
}
}
await walk(source)
if (await dependencyLockHash(workspace, kind) !== identity.lockHash) throw new Error('依赖导入期间项目锁文件变化')
const bytes = Buffer.from(JSON.stringify(manifest) + '\n')
if (bytes.length > limits.manifestBytes) throw new Error('依赖清单超过上限')
await fs.writeFile(join(object, 'manifest.json'), bytes, { flag: 'wx', mode: 0o600 })
const active: SandboxDependencyIdentity = { ...identity, manifestSha256: hash(bytes) }, temporary = join(parent, `${kind}-${id}.tmp`)
try {
await fs.writeFile(temporary, JSON.stringify(active) + '\n', { flag: 'wx', mode: 0o600 })
await fs.rename(temporary, join(parent, `${kind}.json`))
} finally { await fs.unlink(temporary).catch(() => undefined) }
return { identity: active, files: manifest.files.length, bytes: manifest.bytes, excluded: manifest.excluded }
}
export async function stageSandboxDependencies(workspace: string, stage: string, requested: string[], runtimes: SandboxToolchainIdentity[], signal?: AbortSignal): Promise<SandboxDependencyIdentity[]> {
if (!Array.isArray(requested) || requested.length > 2 || new Set(requested).size !== requested.length) throw new Error('依赖缓存选择无效')
const identities: SandboxDependencyIdentity[] = []
const policy = await effectiveSandboxPolicy(workspace)
for (const name of requested) {
assertActive(signal)
const kind = kindValue(name), parent = cacheRoot(workspace)
await directory(join(sandboxConfiguration().stateRoot, 'dependencies')); await directory(parent)
const active = JSON.parse((await regular(join(parent, `${kind}.json`), 4096)).toString('utf8')) as SandboxDependencyIdentity
if (active.kind !== kind || !uuid.test(active.id) || !/^[a-f0-9]{64}$/.test(active.manifestSha256) || !/^[a-f0-9]{64}$/.test(active.lockHash)) throw new Error('依赖缓存身份无效')
const runtime = runtimes.find(item => item.kind === kind)
if (!runtime || runtime.id !== active.runtimeId || runtime.manifestSha256 !== active.runtimeManifestSha256) throw new Error('依赖缓存与所选运行时身份不匹配,请选择运行时或重新导入依赖')
if (await dependencyLockHash(join(stage, 'workspace'), kind) !== active.lockHash) throw new Error('项目配置或锁文件已变化,请重新导入依赖缓存')
const object = join(parent, 'objects', active.id), source = join(object, 'files')
await directory(join(parent, 'objects')); await directory(object); await directory(source)
const bytes = await regular(join(object, 'manifest.json'), limits.manifestBytes)
if (hash(bytes) !== active.manifestSha256) throw new Error('依赖清单摘要不匹配')
const manifest = JSON.parse(bytes.toString('utf8')) as DependencyManifest
if (manifest.version !== 1 || rootKey(manifest.workspaceRoot) !== rootKey(workspace) || manifest.identity.id !== active.id || manifest.identity.kind !== kind || manifest.identity.lockHash !== active.lockHash || manifest.identity.runtimeId !== active.runtimeId || manifest.identity.runtimeManifestSha256 !== active.runtimeManifestSha256
|| !Array.isArray(manifest.files) || manifest.files.length > limits.files || !Number.isSafeInteger(manifest.bytes) || manifest.bytes > limits.bytes) throw new Error('依赖清单归属或容量无效')
const target = kind === 'node' ? join(stage, 'workspace/node_modules') : join(stage, 'home/python-site')
await fs.mkdir(target, { recursive: true })
let total = 0; const seen = new Set<string>(), directories = new Set<string>([source])
for (const file of manifest.files) {
assertActive(signal)
if (!validPath(file.path) || seen.has(file.path.toLowerCase()) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > limits.fileBytes || !/^[a-f0-9]{64}$/.test(file.sha256)) throw new Error('依赖清单条目无效')
if (sandboxPathDenied(policy, join(workspace, kind === 'node' ? 'node_modules' : '.venv/Lib/site-packages', file.path))) throw new Error('依赖文件被当前工作区策略禁止')
seen.add(file.path.toLowerCase())
const path = resolve(source, file.path)
if (!within(source, path)) throw new Error('依赖文件越界')
let ancestor = dirname(path)
while (!directories.has(ancestor)) {
if (!within(source, ancestor)) throw new Error('依赖父目录越界')
await directory(ancestor); directories.add(ancestor); ancestor = dirname(ancestor)
}
const data = await regular(path)
if (data.length !== file.bytes || hash(data) !== file.sha256) throw new Error('依赖文件摘要不匹配')
total += data.length
if (total > limits.bytes) throw new Error('依赖总容量超过上限')
await fs.mkdir(dirname(join(target, file.path)), { recursive: true })
await fs.writeFile(join(target, file.path), data, { flag: 'wx', mode: 0o600 })
}
if (total !== manifest.bytes) throw new Error('依赖清单总容量不匹配')
identities.push(active)
}
return identities
}
+38
View File
@@ -0,0 +1,38 @@
import { promises as fs } from 'node:fs'
import { assertActive } from './policy'
interface Waiting { workspace: string; start(): void; cancel(): void }
const queue: Waiting[] = [], busy = new Set<string>()
const maximumActive = 4, maximumQueued = 32
function drain(): void {
for (let index = 0; index < queue.length && busy.size < maximumActive;) {
const item = queue[index]
if (busy.has(item.workspace)) { index++; continue }
queue.splice(index, 1); busy.add(item.workspace); item.start()
}
}
/** A workspace snapshot observes the previous approved commit, never a concurrent stale copy. */
export async function withSandboxExecution<T>(root: string, signal: AbortSignal | undefined, operation: (canonicalRoot: string) => Promise<T>): Promise<T> {
assertActive(signal)
const real = await fs.realpath(root), workspace = process.platform === 'win32' ? real.toLowerCase() : real
if (queue.length >= maximumQueued || queue.filter(item => item.workspace === workspace).length >= 8) throw new Error('沙盒执行队列已满,请稍后重试')
return new Promise<T>((done, reject) => {
const item: Waiting = {
workspace,
start() {
signal?.removeEventListener('abort', item.cancel)
Promise.resolve().then(() => { assertActive(signal); return operation(real) }).then(done, reject).finally(() => { busy.delete(workspace); drain() })
},
cancel() {
const index = queue.indexOf(item)
if (index < 0) return
queue.splice(index, 1); signal?.removeEventListener('abort', item.cancel)
reject(Object.assign(new Error('排队中的沙盒执行已取消'), { name: 'AbortError' }))
}
}
queue.push(item)
signal?.addEventListener('abort', item.cancel, { once: true })
if (signal?.aborted) item.cancel()
drain()
})
}
+75
View File
@@ -0,0 +1,75 @@
import { spawn } from 'node:child_process'
import { dirname, join, relative, resolve } from 'node:path'
import type { SandboxEffectivePolicy } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { assertActive, within } from './policy'
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
export class SandboxReadError extends Error {
constructor(readonly code: string, message: string) { super(message) }
}
export interface NativeReadResult {
protocol: 1; rootIdentity: string; identity: string; path: string; kind: 'file' | 'directory'; size: number
data?: string; entries?: Array<{ name: string; kind: 'file' | 'directory' | 'link' }>
}
const identityPattern = /^[a-f0-9]{16}:[a-f0-9]{32}$/
const excluded = new Set(['.tmp', 'node_modules', '.venv', 'venv', '__pycache__'])
/** Every read reopens and pins the root/ancestors/target in native code; root identity is fixed for this Job. */
export class NativeWorkspaceReader {
private constructor(private readonly binary: string, readonly policy: SandboxEffectivePolicy, readonly rootIdentity: string) {}
static async create(binary: string, policy: SandboxEffectivePolicy, signal?: AbortSignal): Promise<NativeWorkspaceReader> {
await assertPolicyCurrent(policy)
const root = await NativeWorkspaceReader.invoke(binary, policy.workspaceRoot, 'identity', '.', '-', 1, signal)
if (root.kind !== 'directory' || root.path !== '.' || !identityPattern.test(root.rootIdentity)) throw new SandboxReadError('EPROTO', '原生工作区身份无效')
return new NativeWorkspaceReader(binary, policy, root.rootIdentity)
}
private allowed(path: string): boolean {
const absolute = resolve(this.policy.workspaceRoot, path)
return within(this.policy.workspaceRoot, absolute) && !sandboxPathDenied(this.policy, absolute)
&& !relative(this.policy.workspaceRoot, absolute).split(/[\\/]/).some(part => excluded.has(part.toLowerCase()))
&& !within(resolve(sandboxConfiguration().stateRoot), absolute)
}
async read(operation: 'read' | 'stat' | 'list', input: unknown, signal?: AbortSignal): Promise<NativeReadResult> {
assertActive(signal)
if (typeof input !== 'string' || !input || input.length > 1024 || input !== input.trim()
|| input !== '.' && input.split(/[\\/]/).some(part => !part || part === '.' || part === '..') || !this.allowed(input)) throw new SandboxReadError('EACCES', '文件桥只允许工作区内的非敏感相对路径')
// Absolute paths are rejected even when they happen to point back into this workspace.
if (/^[\\/]|:/.test(input)) throw new SandboxReadError('EACCES', '文件桥不接受绝对路径、设备、UNC 或 ADS')
await assertPolicyCurrent(this.policy)
const result = await NativeWorkspaceReader.invoke(this.binary, this.policy.workspaceRoot, operation, input, this.rootIdentity,
operation === 'list' ? Math.min(this.policy.limits.files, 128) : Math.min(this.policy.limits.fileBytes, 8 * 1024 * 1024), signal)
await assertPolicyCurrent(this.policy)
if (result.rootIdentity !== this.rootIdentity) throw new SandboxReadError('ESTALE', '工作区身份已变化')
if (!this.allowed(result.path)) throw new SandboxReadError('EACCES', '文件最终名称被策略拒绝')
if (result.entries) result.entries = result.entries.filter(entry => entry.kind !== 'link' && this.allowed(join(result.path, entry.name)))
return result
}
private static async invoke(binary: string, root: string, operation: string, path: string, expected: string, limit: number, signal?: AbortSignal): Promise<NativeReadResult> {
assertActive(signal)
const child = spawn(binary, ['--workspace-read', operation, root, path, expected, String(limit)], { cwd: dirname(binary), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const chunks: Buffer[] = []; let bytes = 0, errorText = '', overflow = false
child.stdout.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 12 * 1024 * 1024) { overflow = true; child.kill() } else chunks.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { errorText = (errorText + chunk.toString('utf8')).slice(-2048) })
const abort = (): void => { child.kill() }
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) abort()
const timer = setTimeout(abort, 4000)
try {
const exit = await new Promise<number | null>((done, reject) => { child.once('error', reject); child.once('close', done) })
assertActive(signal)
if (overflow) throw new SandboxReadError('E2BIG', '原生读取结果超过上限')
if (exit !== 0) {
const code = /identity changed/.test(errorText) ? 'ESTALE' : /win32=(?:2|3)(?:\D|$)/.test(errorText) ? 'ENOENT'
: /win32=(?:32|33)(?:\D|$)/.test(errorText) ? 'EBUSY' : /exceeds .*limit/.test(errorText) ? 'E2BIG' : 'EACCES'
throw new SandboxReadError(code, `原生文件读取被拒绝(${code})`)
}
const result = JSON.parse(Buffer.concat(chunks).toString('utf8')) as NativeReadResult
if (result.protocol !== 1 || !identityPattern.test(result.rootIdentity) || !identityPattern.test(result.identity)
|| typeof result.path !== 'string' || !['file', 'directory'].includes(result.kind) || !Number.isSafeInteger(result.size) || result.size < 0
|| operation === 'read' && (typeof result.data !== 'string' || Buffer.from(result.data, 'base64').length !== result.size)
|| operation === 'list' && (!Array.isArray(result.entries) || result.entries.length > 128 || result.entries.some(entry => typeof entry.name !== 'string' || /[\\/]/.test(entry.name) || !['file', 'directory', 'link'].includes(entry.kind)))) throw new SandboxReadError('EPROTO', '原生文件读取报告无效')
return result
} finally { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
}
}
+120
View File
@@ -0,0 +1,120 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { dirname, relative, resolve } from 'node:path'
import { sandboxConfiguration } from './configuration'
import { protectedPath, within } from './policy'
import { verifiedBinary } from './native-component'
import { randomUUID } from 'node:crypto'
interface NativeReply { ok?: boolean; protocol?: number; rootIdentity?: string; kind?: 'file' | 'directory' | 'missing'; identity?: string; data?: string; entries?: string[]; error?: string }
const identityPattern = /^[a-f0-9]{16}:[a-f0-9]{32}$/
export interface WorkspaceEntry { kind: 'file' | 'directory' | 'missing'; identity?: string; bytes?: Buffer }
/** A private host channel pins the root for the complete commit/compensation/recovery. */
export class NativeWorkspaceWriter {
private output = ''
private errorText = ''
private failure?: Error
private pending?: { resolve(value: NativeReply): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
private readonly exited: Promise<void>
readonly ready: Promise<NativeReply>
rootIdentity = ''
get available(): boolean { return !this.failure }
private constructor(private readonly child: ChildProcessWithoutNullStreams, readonly root: string, private readonly gitMetadata = false) {
this.ready = this.response()
this.exited = new Promise(done => {
child.once('close', () => { this.fail(new Error(`原生写入通道已退出${this.errorText ? `:${this.errorText}` : ''}`)); done() })
})
child.on('error', error => this.fail(error))
child.stdin.on('error', error => this.fail(error))
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
this.output += chunk
if (this.output.length > (this.gitMetadata ? 180 : 12) * 1024 * 1024) { this.fail(new Error('原生写入回复超过上限')); return }
const end = this.output.indexOf('\n')
if (end < 0) return
const text = this.output.slice(0, end); this.output = this.output.slice(end + 1)
const pending = this.pending
if (!pending || this.output.length) { this.fail(new Error('原生写入回复顺序无效')); return }
this.pending = undefined; clearTimeout(pending.timer)
try { pending.resolve(JSON.parse(text) as NativeReply) }
catch { pending.reject(new Error('原生写入回复无效')); this.fail(new Error('原生写入回复无效')) }
})
child.stderr.on('data', (chunk: Buffer) => { this.errorText = (this.errorText + chunk.toString('utf8')).slice(-2048) })
}
static async create(root: string, expectedIdentity?: string, gitMetadata = false): Promise<NativeWorkspaceWriter> {
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('当前平台缺少原生文件事务后端')
if (expectedIdentity !== undefined && !identityPattern.test(expectedIdentity)) throw new Error('工作区身份无效')
const binary = await verifiedBinary(true, false, gitMetadata)
const writer = new NativeWorkspaceWriter(spawn(binary, [gitMetadata ? '--git-metadata-write' : '--workspace-write', root, expectedIdentity ?? '-'],
{ cwd: dirname(binary), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }), root, gitMetadata)
try {
const ready = await writer.ready
if (ready.protocol !== 1 || typeof ready.rootIdentity !== 'string' || !identityPattern.test(ready.rootIdentity)
|| expectedIdentity !== undefined && ready.rootIdentity !== expectedIdentity) throw new Error('原生工作区身份校验失败')
writer.rootIdentity = ready.rootIdentity
return writer
} catch (error) { await writer.close(); throw error }
}
private fail(error: Error): void {
this.failure ??= error
if (this.pending) { clearTimeout(this.pending.timer); this.pending.reject(this.failure); this.pending = undefined }
this.child.kill()
}
private response(): Promise<NativeReply> {
return new Promise((resolve, reject) => {
if (this.failure) { reject(this.failure); return }
if (this.pending) { reject(new Error('原生文件事务不允许并发请求')); return }
this.pending = { resolve, reject, timer: setTimeout(() => this.fail(new Error('原生文件事务请求超时')), this.gitMetadata ? 60_000 : 15_000) }
})
}
private path(input: string): string {
const absolute = resolve(this.root, input), path = relative(this.root, absolute)
if (!path || path.length > 1024 || !within(this.root, absolute) || protectedPath(path) && !(this.gitMetadata && path === '.git')
|| within(sandboxConfiguration().stateRoot, absolute)) throw new Error('原生文件事务目标不在允许的工作区范围内')
return path.split(/[\\/]/).join('\\')
}
private async request(operation: string, input: string, expected = '-', bytes?: Buffer, nonce = '-'): Promise<NativeReply> {
const path = this.path(input)
if (bytes && bytes.length > (this.gitMetadata ? 128 : 8) * 1024 * 1024) throw new Error('原生文件事务超过单文件容量上限')
if (this.failure) throw this.failure
const reply = this.response()
this.child.stdin.write(`${operation}\t${Buffer.from(path).toString('hex')}\t${expected}\t${bytes?.toString('hex') ?? ''}\t${nonce}\n`)
const result = await reply
if (result.ok !== true) throw new Error(`原生文件事务被拒绝:${typeof result.error === 'string' ? result.error.slice(0, 2048) : '无效回复'}`)
return result
}
async inspect(path: string, read = false): Promise<WorkspaceEntry> {
const result = await this.request(read ? 'read' : 'stat', path)
if (!['file', 'directory', 'missing'].includes(result.kind ?? '') || result.kind !== 'missing' && (typeof result.identity !== 'string' || !identityPattern.test(result.identity))
|| read && result.kind === 'file' && (typeof result.data !== 'string' || result.data.length > (this.gitMetadata ? 180 : 12) * 1024 * 1024)) throw new Error('原生文件事务元数据无效')
return { kind: result.kind!, identity: result.identity, ...(read && result.kind === 'file' ? { bytes: Buffer.from(result.data!, 'base64') } : {}) }
}
async list(path: string): Promise<string[]> {
const result = await this.request('list', path)
if (!Array.isArray(result.entries) || result.entries.length > 10_000 || result.entries.some(name => typeof name !== 'string' || !name || /[\\/\x00-\x1f]/.test(name) || name === '.' || name === '..')) throw new Error('原生文件事务目录回复无效')
return result.entries
}
async replace(path: string, expectedHash: string | null, bytes: Buffer | null, nonce = randomUUID().replaceAll('-', '')): Promise<void> {
if (expectedHash !== null && !/^[a-f0-9]{64}$/.test(expectedHash) || !/^[a-f0-9]{32}$/.test(nonce)) throw new Error('原生文件事务摘要或变更标识无效')
await this.request(bytes === null ? 'unlink' : 'write', path, expectedHash ?? '-', bytes ?? undefined, nonce)
}
async cleanup(path: string, nonce: string, beforeHash: string | null, afterHash: string | null): Promise<void> {
if (!/^[a-f0-9]{32}$/.test(nonce) || ![beforeHash, afterHash].every(hash => hash === null || /^[a-f0-9]{64}$/.test(hash))) throw new Error('原生文件事务恢复标识无效')
await this.request('cleanup', path, `${beforeHash ?? '-'}:${afterHash ?? '-'}`, undefined, nonce)
}
async mkdir(path: string): Promise<string> {
const result = await this.request('mkdir', path)
if (typeof result.identity !== 'string' || !identityPattern.test(result.identity)) throw new Error('原生目录创建身份无效')
return result.identity
}
async rmdir(path: string): Promise<void> {
const current = await this.inspect(path)
if (current.kind !== 'directory') throw new Error('目录在删除前被替换')
await this.request('rmdir', path, current.identity)
}
async close(): Promise<void> {
this.child.stdin.end()
const timer = setTimeout(() => this.child.kill(), 2000)
try { await this.exited } finally { clearTimeout(timer) }
}
}
+122
View File
@@ -0,0 +1,122 @@
import { promises as fs } from 'node:fs'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { dirname, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { sandboxConfiguration } from './configuration'
import { stageSandboxToolchains } from './toolchains'
import { verifiedBinary } from './native-component'
import { sealWorkspace, saveWorkspaceRecord, workspaceRecord } from './workspace'
import { assertActive } from './policy'
import { assertPolicyCurrent } from './policy-store'
import type { SandboxEffectivePolicy } from '@shared/sandbox'
export interface GitControl {
root: string
repository: string
run(args: string[], options?: { input?: Buffer; allowFailure?: boolean; network?: boolean; config?: Record<string, string>; environment?: Record<string, string>; timeoutMs?: number }): Promise<{ code: number; stdout: Buffer; stderr: string }>
close(): Promise<void>
}
const activeControls = new Set<() => void>()
export function disposeGitControls(): void { for (const cancel of [...activeControls]) cancel() }
/** Fixed, verified Git in a parent-monitored native Job; never a model shell. */
export async function createGitControl(policy: SandboxEffectivePolicy, signal?: AbortSignal): Promise<GitControl> {
assertActive(signal)
const parent = join(sandboxConfiguration().stateRoot, 'git-control')
await fs.mkdir(parent, { recursive: true })
const root = await fs.mkdtemp(join(parent, 'operation-'))
await sealWorkspace(root)
const owner = { version: 1, root, pid: process.pid, nonce: randomUUID(), createdAt: Date.now(), state: 'active' }
await saveWorkspaceRecord(join(root, 'owner.json'), owner, false)
for (const name of ['workspace', 'repository', 'home', 'tmp']) await fs.mkdir(join(root, name))
try { await stageSandboxToolchains(root, ['git'], signal) }
catch (error) { await fs.rm(root, { recursive: true, force: true }).catch(() => undefined); throw error }
const binary = await verifiedBinary(true, true, true), tool = join(root, 'tools/git')
let running = false
return {
root, repository: join(root, 'repository'),
async run(args, options = {}) {
if (running) throw new Error('Git control operations must be sequential')
if (args.length > 128 || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))) throw new Error('Invalid Git control argv')
assertActive(signal); await assertPolicyCurrent(policy)
const config: Record<string, string> = {
'core.hooksPath': join(tool, 'empty-hooks'), 'core.fsmonitor': 'false', 'credential.helper': '',
'commit.gpgSign': 'false', 'tag.gpgSign': 'false', 'maintenance.auto': 'false', 'gc.auto': '0',
'diff.autoRefreshIndex': 'false', 'core.attributesFile': 'NUL', 'core.excludesFile': 'NUL',
'protocol.allow': 'never', 'http.followRedirects': 'false', 'http.sslVerify': 'true',
...options.config
}
const win = process.env.SystemRoot ?? 'C:\\Windows'
const environment: Record<string, string> = {
SYSTEMROOT: win, WINDIR: win, SYSTEMDRIVE: win.slice(0, 2), COMSPEC: join(win, 'System32/cmd.exe'),
PATH: `${tool};${join(win, 'System32')}`, HOME: join(root, 'home'), USERPROFILE: join(root, 'home'),
TEMP: join(root, 'tmp'), TMP: join(root, 'tmp'), LC_ALL: 'C.UTF-8',
GIT_DIR: join(root, 'repository'), GIT_WORK_TREE: join(root, 'workspace'),
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL',
GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: '', GIT_EDITOR: '', GIT_SEQUENCE_EDITOR: '',
GIT_EXEC_PATH: tool, GIT_TEMPLATE_DIR: join(tool, 'empty-template'), GIT_NO_LAZY_FETCH: '1',
GIT_ALLOW_PROTOCOL: options.network ? 'https:http:ssh' : '',
GIT_CONFIG_COUNT: String(Object.keys(config).length), ...options.environment
}
Object.entries(config).forEach(([key, value], index) => { environment[`GIT_CONFIG_KEY_${index}`] = key; environment[`GIT_CONFIG_VALUE_${index}`] = value })
const fields = (values: string[]): string => values.map(value => Buffer.from(value).toString('hex')).join('\n') + '\n'
await fs.writeFile(join(root, 'arguments'), fields(args))
await fs.writeFile(join(root, 'environment'), fields(Object.entries(environment).map(([name, value]) => `${name}=${value}`)))
await fs.writeFile(join(root, 'input'), options.input ?? Buffer.alloc(0))
await fs.unlink(join(root, 'native-lease.json')).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error })
const timeout = options.timeoutMs ?? 120000
running = true
const child = spawn(binary, ['--git-control', root, String(timeout)], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const chunks: Buffer[] = []; let length = 0, stderr = '', failure: Error | undefined, checking = false
const fail = (error: Error): void => { failure ??= error; child.kill() }
const revoke = (): void => fail(Object.assign(new Error('Git control cancelled or policy revoked'), { name: 'AbortError' }))
activeControls.add(revoke)
child.stdout.on('data', (bytes: Buffer) => { length += bytes.length; if (length > 32 * 1024 * 1024) fail(new Error('Git control output exceeds limit')); else chunks.push(bytes) })
child.stderr.on('data', (bytes: Buffer) => { stderr = (stderr + bytes.toString('utf8')).slice(-2 * 1024 * 1024) })
const abort = (): void => fail(Object.assign(new Error('Git operation cancelled'), { name: 'AbortError' }))
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort()
const timer = setTimeout(() => fail(new Error('Git control timeout')), timeout + 5000)
const watch = setInterval(() => {
if (checking) return
checking = true
void assertPolicyCurrent(policy).catch(error => fail(error as Error)).finally(() => { checking = false })
}, 250)
try {
const code = await new Promise<number>((done, reject) => { child.once('error', reject); child.once('close', code => done(code ?? 130)) })
if (failure) throw failure
if (code !== 0 && !options.allowFailure) throw new Error(`Git control failed (${code}): ${stderr.slice(-4096)}`)
return { code, stdout: Buffer.concat(chunks), stderr }
} finally { clearTimeout(timer); clearInterval(watch); signal?.removeEventListener('abort', abort); activeControls.delete(revoke); running = false }
},
async close() {
if (running) throw new Error('Cannot clean an active Git control Job')
await saveWorkspaceRecord(join(root, 'owner.json'), { ...owner, state: 'closed' }).catch(() => undefined)
await fs.rm(root, { recursive: true, force: true }).catch(async () => {
await saveWorkspaceRecord(join(root, 'owner.json'), { ...owner, state: 'cleanup-required' }).catch(() => undefined)
})
}
}
}
export async function cleanupGitControls(): Promise<Array<{ id: string; state: 'removed' | 'active' | 'invalid'; reason?: string }>> {
const parent = resolve(sandboxConfiguration().stateRoot, 'git-control'), entries = await fs.readdir(parent, { withFileTypes: true }).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error })
if (entries.length > 10000) throw new Error('Too many Git control records')
const alive = (pid: number): boolean => { try { process.kill(pid, 0); return true } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; throw error } }
const result: Array<{ id: string; state: 'removed' | 'active' | 'invalid'; reason?: string }> = []
for (const entry of entries) {
if (!/^operation-[a-zA-Z0-9_-]{6,}$/.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) { result.push({ id: entry.name, state: 'invalid' }); continue }
const root = resolve(parent, entry.name)
try {
if ((await fs.realpath(dirname(root))).toLowerCase() !== (await fs.realpath(parent)).toLowerCase() || (await fs.realpath(root)).toLowerCase() !== root.toLowerCase()) throw new Error('Git cleanup root changed or escaped')
const owner = await workspaceRecord<{ version: number; root: string; pid: number; nonce: string; state: string }>(join(root, 'owner.json'), 4096)
if (owner.version !== 1 || owner.root.toLowerCase() !== root.toLowerCase() || !Number.isSafeInteger(owner.pid) || owner.pid < 1 || !/^[a-f0-9-]{36}$/.test(owner.nonce)) throw new Error('Invalid Git control owner')
if (owner.state === 'active' && alive(owner.pid)) { result.push({ id: entry.name, state: 'active' }); continue }
const lease = await workspaceRecord<{ version: number; root: string; pid: number }>(join(root, 'native-lease.json'), 4096).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
if (lease && (lease.version !== 1 || lease.root.toLowerCase() !== root.toLowerCase() || !Number.isSafeInteger(lease.pid) || lease.pid < 1)) throw new Error('Invalid native Git control lease')
if (lease && alive(lease.pid)) { result.push({ id: entry.name, state: 'active' }); continue }
await fs.rm(root, { recursive: true, force: false }); result.push({ id: entry.name, state: 'removed' })
} catch (error) { result.push({ id: entry.name, state: 'invalid', reason: (error as Error).message }) }
}
return result
}
+89
View File
@@ -0,0 +1,89 @@
import { promises as fs } from 'node:fs'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { sandboxConfiguration } from './configuration'
import { verifiedBinary } from './native-component'
import { saveWorkspaceRecord, sealWorkspace, workspaceRecord } from './workspace'
import { digest } from './transactions'
export type GitCredential = { type: 'https'; username: string; secret: string; caCertificate?: string } | { type: 'ssh'; privateKey: string; knownHosts: string }
interface StoredCredential { version: 1; id: string; url: string; type: GitCredential['type']; ciphertext: string; createdAt: number }
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
export function gitRemoteUrl(input: string): URL {
if (typeof input !== 'string' || input.length > 2048 || /[\x00-\x20\\]/.test(input)) throw new Error('Invalid Git remote URL')
let value = input
if (/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:[^:]+$/.test(value)) { const [userHost, path] = value.split(':'); value = `ssh://${userHost}${path.startsWith('/') ? path : `/~/${path}`}` }
const url = new URL(value)
if (!['https:', 'http:', 'ssh:'].includes(url.protocol) || url.password || url.search || url.hash || url.hostname.startsWith('-') || url.protocol !== 'ssh:' && url.username
|| url.protocol === 'ssh:' && !/^[a-zA-Z0-9._-]{1,64}$/.test(url.username)
|| url.protocol === 'http:' && !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)
|| !url.pathname || url.pathname === '/' || /[\x00-\x20'"`$;&|<>]/.test(decodeURIComponent(url.pathname)) || /%(?:00|0a|0d|2f|5c)/i.test(url.pathname)) throw new Error('Git remote requires a credential-free HTTPS URL, a pinned SSH URL, or an unauthenticated loopback HTTP fixture')
return url
}
export function gitRemoteEndpoint(input: string, url: URL): string {
return /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:[^:]+$/.test(input) ? input : url.href.replace(/\/$/, '')
}
const canonical = (url: URL): string => url.href.replace(/\/$/, '')
async function crypt(bytes: Buffer, protect: boolean): Promise<Buffer> {
const child = spawn(await verifiedBinary(true, true, true), [protect ? '--git-protect' : '--git-unprotect'], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
let output = '', error = ''
child.stdout.on('data', (data: Buffer) => { output += data.toString('utf8'); if (output.length > 262144) child.kill() })
child.stderr.on('data', (data: Buffer) => { error = (error + data.toString('utf8')).slice(-1024) })
child.stdin.on('error', () => undefined)
const timer = setTimeout(() => child.kill(), 10000)
child.stdin.end(bytes.toString('hex'))
try {
const code = await new Promise<number | null>((done, reject) => { child.once('error', reject); child.once('close', done) })
if (code !== 0) throw new Error(`Git credential protection failed: ${error}`)
const data = JSON.parse(output).data
if (typeof data !== 'string' || !/^[a-f0-9]+$/.test(data) || data.length > 131072 || data.length % 2) throw new Error('Invalid Git credential protection reply')
return Buffer.from(data, 'hex')
} finally { clearTimeout(timer) }
}
export async function importGitCredential(urlInput: string, credential: GitCredential): Promise<{ id: string; url: string; type: string }> {
const url = gitRemoteUrl(urlInput)
if (credential.type === 'https') {
if (url.protocol !== 'https:' || typeof credential.username !== 'string' || !credential.username || /[:\r\n\0]/.test(credential.username) || typeof credential.secret !== 'string' || !credential.secret || /[\r\n\0]/.test(credential.secret) || credential.secret.length > 8192) throw new Error('Invalid HTTPS Git credential')
if (credential.caCertificate !== undefined && (credential.caCertificate.length > 32768 || !credential.caCertificate.includes('BEGIN CERTIFICATE'))) throw new Error('Invalid explicit Git CA certificate')
} else if (credential.type === 'ssh') {
if (url.protocol !== 'ssh:' || typeof credential.privateKey !== 'string' || !credential.privateKey.includes('PRIVATE KEY') || credential.privateKey.length > 32768 || typeof credential.knownHosts !== 'string' || !credential.knownHosts || credential.knownHosts.length > 16384 || credential.knownHosts.includes('\0')) throw new Error('SSH requires an explicit key and pinned known_hosts data')
} else throw new Error('Unsupported Git credential type')
const root = join(sandboxConfiguration().stateRoot, 'git-credentials')
const created = !await fs.lstat(root).catch(() => null)
await fs.mkdir(root, { recursive: true }); if (created) await sealWorkspace(root)
const id = randomUUID(), scope = canonical(url), ciphertext = (await crypt(Buffer.from(JSON.stringify({ url: scope, credential })), true)).toString('hex')
await saveWorkspaceRecord(join(root, `${id}.json`), { version: 1, id, url: scope, type: credential.type, ciphertext, createdAt: Date.now() } satisfies StoredCredential, false)
return { id, url: scope, type: credential.type }
}
export async function gitCredentialIdentity(id: string, url: URL): Promise<string> {
if (!uuid.test(id)) throw new Error('Invalid Git credential ID')
const value = await workspaceRecord<StoredCredential>(join(sandboxConfiguration().stateRoot, 'git-credentials', `${id}.json`), 262144)
if (value.version !== 1 || value.id !== id || value.url !== canonical(url) || !['https', 'ssh'].includes(value.type) || typeof value.ciphertext !== 'string' || !/^[a-f0-9]+$/.test(value.ciphertext)) throw new Error('Git credential is not scoped to this exact remote')
return digest(Buffer.from(JSON.stringify(value)))
}
export async function loadGitCredential(id: string, url: URL): Promise<GitCredential> {
await gitCredentialIdentity(id, url)
const value = await workspaceRecord<StoredCredential>(join(sandboxConfiguration().stateRoot, 'git-credentials', `${id}.json`), 262144)
const bytes = await crypt(Buffer.from(value.ciphertext, 'hex'), false)
try {
const payload = JSON.parse(bytes.toString('utf8')) as { url: string; credential: GitCredential }
if (payload.url !== canonical(url) || payload.credential.type !== value.type) throw new Error('Git credential protected scope mismatch')
return payload.credential
} finally { bytes.fill(0) }
}
export async function listGitCredentials(): Promise<Array<{ id: string; url: string; type: string; createdAt: number }>> {
const root = join(sandboxConfiguration().stateRoot, 'git-credentials'), names = await fs.readdir(root).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error })
if (names.length > 1024) throw new Error('Too many Git credentials')
const values = []
for (const name of names) {
if (!uuid.test(name.replace(/\.json$/, ''))) throw new Error('Invalid Git credential inventory')
const value = await workspaceRecord<StoredCredential>(join(root, name), 262144)
values.push({ id: value.id, url: value.url, type: value.type, createdAt: value.createdAt })
}
return values
}
export async function removeGitCredential(id: string): Promise<void> {
if (!uuid.test(id)) throw new Error('Invalid Git credential ID')
await fs.unlink(join(sandboxConfiguration().stateRoot, 'git-credentials', `${id}.json`))
}
+455
View File
@@ -0,0 +1,455 @@
import { promises as fs } from 'node:fs'
import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { SandboxEffectivePolicy, SandboxGitHostView } from '@shared/sandbox'
import { effectiveSandboxPolicy, assertPolicyCurrent } from './policy-store'
import { withSandboxExecution } from './execution'
import { authorizeSandboxWrite, type SandboxAuthorizationContext } from './authorizations'
import { commitChanges, digest, fileChangeDigest, restoreTransaction, type FileChange, type SandboxCallIdentity } from './transactions'
import { PersistentWorkspace, saveWorkspaceRecord, workspaceRecord, workspaceKey, sealWorkspace } from './workspace'
import { createGitControl, type GitControl } from './git-control'
import { copySandboxGitRepository, replaceSandboxGitRepository, recoverSandboxGitLock, resetSandboxGit, sandboxGitIdentity, type GitIdentity } from './git-repository'
import { assertGitLayout, assertHostGitBinding, captureHostGit, configText, gitTree, hostGitRoot, loadHostGitBinding, newHostGitBinding, readGitTree, readSafeGitConfig, refreshHostGit, saveHostGitBinding, sourceKey, writeGitTree, type GitConfigValue, type GitSourceFile, type HostGitBinding, type HostGitLayout, type HostGitSnapshot } from './git-host-source'
import { NativeWorkspaceWriter } from './filesystem-writer'
import { sandboxConfiguration } from './configuration'
import { assertActive } from './policy'
export interface GitHostContext extends SandboxCallIdentity, SandboxAuthorizationContext { workspaceRoot: string; sessionId: string }
export type GitHostView = SandboxGitHostView
interface GroupRoot { phase: string; root: string; identity: string }
interface GroupLock { scope: 'common' | 'worktree'; path: string; hash: string; parents: Array<{ path: string; identity?: string }> }
interface GitGroup {
version: 1; id: string; sessionId: string; workspaceRoot: string; policyFingerprint: string; layout: HostGitLayout
authorizationId: string; gitRevision: GitIdentity; workspaceRevision: string; createdAt: number
status: 'prepared' | 'applying' | 'rolling-back' | 'committed' | 'rolled-back' | 'recovery-required'
phases: GroupRoot[]; locks: GroupLock[]; transactions: Record<string, string>; reason?: string
}
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
const present = async (path: string): Promise<boolean> => !!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
const groupPath = (binding: HostGitBinding): string => join(hostGitRoot(binding.workspaceRoot, binding.sessionId), 'commit.json')
const sourceDigest = (snapshot: Pick<HostGitSnapshot, 'layout' | 'files'>): string => digest(Buffer.from(JSON.stringify({ layout: snapshot.layout, files: snapshot.files })))
interface Initialization {
version: 1; id: string; sessionId: string; workspaceRoot: string; workspaceIdentity: string; policyFingerprint: string
authorizationId: string; status: 'applying' | 'committed' | 'rolled-back' | 'recovery-required'; createdAt: number
}
async function newRepository(policy: SandboxEffectivePolicy, ctx: GitHostContext, draft: PersistentWorkspace, control: GitControl): Promise<GitHostView> {
const writer = await NativeWorkspaceWriter.create(policy.workspaceRoot, draft.rootIdentity, true)
try { if ((await writer.inspect('.git')).kind !== 'missing') throw new Error('Use import for an existing host repository') } finally { await writer.close() }
const root = hostGitRoot(policy.workspaceRoot, ctx.sessionId)
if (await present(join(root, 'clone.json'))) throw new Error('A new repository draft already exists')
await draft.synchronize()
await control.run(['init', '--template=' + join(control.root, 'tools/git/empty-template'), '--initial-branch=main'])
await fs.writeFile(join(control.repository, 'config'), configText(await readSafeGitConfig(control, await fs.readFile(join(control.repository, 'config')))))
const revision = await replaceSandboxGitRepository(policy, draft.namespace, control.repository, undefined, 'empty')
const created = !await present(root); await fs.mkdir(root, { recursive: true })
if (created) await sealWorkspace(root)
await saveWorkspaceRecord(join(root, 'clone.json'), { version: 1, kind: 'init', workspaceRoot: policy.workspaceRoot, sessionId: ctx.sessionId, rootIdentity: draft.rootIdentity, policyFingerprint: policy.fingerprint, revision, createdAt: Date.now() })
return { mode: 'new-repository', state: 'ready', sessionId: ctx.sessionId, revision: revision.id, pendingMetadata: true, workspace: draft.view() }
}
async function initializationTransactions(intent: Initialization): Promise<Array<{ id: string; workspaceRoot: string; workspaceIdentity: string; status: string; phase: string; directories: Array<{ path: string; createdIdentity?: string }> }>> {
const root = join(sandboxConfiguration().stateRoot, 'transactions'), names = await fs.readdir(root).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error })
if (names.length > 10000) throw new Error('Too many transactions to recover Git initialization')
const values = []
for (const name of names) {
if (!uuid.test(name) || !await present(join(root, name, 'manifest.json'))) continue
const value = await workspaceRecord<{ id: string; workspaceRoot: string; workspaceIdentity: string; status: string; identity: SandboxCallIdentity; directories: Array<{ path: string; createdIdentity?: string }> }>(join(root, name, 'manifest.json'), 64 * 1024 * 1024)
if (value.identity?.runId !== intent.id || value.identity.authorizationId !== intent.authorizationId) continue
if (value.identity.sessionId !== intent.sessionId || !['initialize-root', 'initialize-metadata'].includes(value.identity.toolCallId ?? '')
|| value.workspaceRoot !== (value.identity.toolCallId === 'initialize-root' ? intent.workspaceRoot : join(intent.workspaceRoot, '.git'))) throw new Error('Git initialization transaction owner mismatch')
values.push({ ...value, phase: value.identity.toolCallId! })
}
if (values.length > 2) throw new Error('Ambiguous Git initialization transactions')
return values
}
async function recoverInitialization(policy: SandboxEffectivePolicy, ctx: GitHostContext, draft: PersistentWorkspace, control: GitControl, rollback = false): Promise<HostGitBinding | null> {
const root = hostGitRoot(policy.workspaceRoot, ctx.sessionId), path = join(root, 'initialization.json'), intent = await workspaceRecord<Initialization>(path)
if (intent.version !== 1 || !uuid.test(intent.id) || intent.workspaceRoot !== policy.workspaceRoot || intent.sessionId !== ctx.sessionId || intent.policyFingerprint !== policy.fingerprint || intent.workspaceIdentity !== draft.rootIdentity) throw new Error('Git initialization ownership/policy changed')
const records = await initializationTransactions(intent), directory = records.find(value => value.phase === 'initialize-root'), metadata = records.find(value => value.phase === 'initialize-metadata')
const identity = directory?.directories.find(entry => entry.path === '.git')?.createdIdentity
if (directory && !identity) throw new Error('Git directory creation was interrupted before identity acknowledgement; inspect the native transaction before recovery')
if (metadata && metadata.workspaceIdentity !== identity) throw new Error('Initialized Git directory identity changed')
let binding: HostGitBinding | null = null
if (!rollback && directory?.status === 'committed' && metadata?.status === 'committed') {
const writer = await NativeWorkspaceWriter.create(join(policy.workspaceRoot, '.git'), identity, true); await writer.close()
const host = await captureHostGit(policy, control), revision = await sandboxGitIdentity(policy.workspaceRoot, draft.namespace)
if (!revision) throw new Error('Initialized Git draft has no saved candidate')
binding = { ...newHostGitBinding(policy, ctx.sessionId, host, revision), initializationPending: true }
await saveHostGitBinding(binding); intent.status = 'committed'
} else {
if (metadata && metadata.status !== 'rolled-back') await restoreTransaction(metadata.id, metadata.workspaceRoot)
if (directory && directory.status !== 'rolled-back') await restoreTransaction(directory.id, directory.workspaceRoot)
intent.status = 'rolled-back'
}
await fs.mkdir(join(root, 'initializations'), { recursive: true }); await saveWorkspaceRecord(join(root, 'initializations', `${intent.id}.json`), intent)
await fs.unlink(path); return binding
}
async function bootstrapHost(policy: SandboxEffectivePolicy, ctx: GitHostContext, draft: PersistentWorkspace, control: GitControl): Promise<HostGitBinding> {
const root = hostGitRoot(policy.workspaceRoot, ctx.sessionId), clone = await workspaceRecord<{ version: number; sessionId: string; workspaceRoot: string; rootIdentity: string; policyFingerprint: string }>(join(root, 'clone.json'))
if (clone.version !== 1 || clone.sessionId !== ctx.sessionId || clone.workspaceRoot !== policy.workspaceRoot || clone.rootIdentity !== draft.rootIdentity || clone.policyFingerprint !== policy.fingerprint) throw new Error('New Git repository ownership/policy changed')
if (await present(join(root, 'initialization.json'))) throw new Error('Recover the interrupted host Git initialization first')
const copied = await copySandboxGitRepository(policy.workspaceRoot, draft.namespace, control.repository)
const skeletonConfig = configText((await readSafeGitConfig(control, await fs.readFile(join(control.repository, 'config')))).filter(entry => /^(?:core\.repositoryformatversion|extensions\.objectformat)$/.test(entry.key)))
const skeleton = [{ path: 'HEAD', beforeHash: null, after: Buffer.from('ref: refs/heads/main\n') }, { path: 'config', beforeHash: null, after: skeletonConfig }]
const target = digest(Buffer.from(JSON.stringify({ project: policy.workspaceRoot, identity: draft.rootIdentity, candidate: copied.identity, files: fileChangeDigest(skeleton) })))
const approval = await authorizeSandboxWrite(policy, `为当前项目创建新的宿主 .git?\n项目:${policy.workspaceRoot}\n先建立空仓库,随后继续审批草稿文件与 Git 历史的正式提交;现有宿主仓库不会被覆盖。`, ctx, target)
if (!approval) throw new Error('Host Git initialization was not approved; the new repository remains private')
const owner = await NativeWorkspaceWriter.create(policy.workspaceRoot, draft.rootIdentity, true)
try { if ((await owner.inspect('.git')).kind !== 'missing') throw new Error('A host Git repository appeared while initialization approval was pending') } finally { await owner.close() }
await draft.assertCurrent(ctx.signal); await approval.consume(target)
const intent: Initialization = { version: 1, id: randomUUID(), sessionId: ctx.sessionId, workspaceRoot: policy.workspaceRoot, workspaceIdentity: draft.rootIdentity, policyFingerprint: policy.fingerprint, authorizationId: approval.id, status: 'applying', createdAt: Date.now() }
await saveWorkspaceRecord(join(root, 'initialization.json'), intent, false)
try {
const identity = { runId: intent.id, sessionId: ctx.sessionId, authorizationId: approval.id, policyRevision: policy.revision, policyFingerprint: policy.fingerprint }
const directoryId = await commitChanges(policy.workspaceRoot, [], { ...identity, toolCallId: 'initialize-root' }, ctx.signal,
{ workspaceIdentity: draft.rootIdentity, gitMetadata: true, beforeApply: () => approval.assertValid(target), check: () => approval.assertValid(target) }, [{ path: '.git', before: false, after: true }])
const manifest = await workspaceRecord<{ directories: Array<{ path: string; createdIdentity: string }> }>(join(sandboxConfiguration().stateRoot, 'transactions', directoryId, 'manifest.json'))
const gitIdentity = manifest.directories.find(entry => entry.path === '.git')!.createdIdentity
await commitChanges(join(policy.workspaceRoot, '.git'), skeleton, { ...identity, toolCallId: 'initialize-metadata' }, ctx.signal,
{ workspaceIdentity: gitIdentity, gitMetadata: true, beforeApply: () => approval.assertValid(target), check: () => approval.assertValid(target) }, [{ path: 'objects', before: false, after: true }, { path: 'refs', before: false, after: true }])
return (await recoverInitialization(policy, ctx, draft, control))!
} catch (error) {
try { await recoverInitialization(policy, ctx, draft, control, true) } catch { intent.status = 'recovery-required'; await saveWorkspaceRecord(join(root, 'initialization.json'), intent).catch(() => undefined) }
throw error
}
}
export async function hostGitStatus(workspaceRoot: string, sessionId: string): Promise<GitHostView> {
const policy = await effectiveSandboxPolicy(workspaceRoot), binding = await loadHostGitBinding(policy, sessionId)
if (!binding) {
const root = hostGitRoot(policy.workspaceRoot, sessionId), clonePath = join(root, 'clone.json')
if (!await present(clonePath)) return { mode: 'host-mirror', state: 'unimported', sessionId }
const clone = await workspaceRecord<{ kind?: string; workspaceRoot: string; sessionId: string; rootIdentity: string; policyFingerprint: string }>(clonePath)
if (clone.workspaceRoot !== policy.workspaceRoot || clone.sessionId !== sessionId) throw new Error('New Git draft owner mismatch')
const owner = await NativeWorkspaceWriter.create(policy.workspaceRoot, undefined, true), identity = owner.rootIdentity; await owner.close()
if (identity !== clone.rootIdentity) return { mode: clone.kind === 'init' ? 'new-repository' : 'remote-clone', state: 'stale', sessionId }
const workspace = await PersistentWorkspace.peek(policy, sessionId, identity)
return { mode: clone.kind === 'init' ? 'new-repository' : 'remote-clone', state: clone.policyFingerprint !== policy.fingerprint ? 'stale' : await present(join(root, 'initialization.json')) ? 'interrupted' : workspace.state === 'busy' ? 'busy' : 'ready', sessionId,
revision: (await sandboxGitIdentity(policy.workspaceRoot, workspaceKey(policy.workspaceRoot, sessionId)))?.id, pendingMetadata: true, workspace }
}
if (binding.policyFingerprint !== policy.fingerprint) return { mode: 'host-mirror', state: 'stale', sessionId }
try { await assertGitLayout(binding.layout) } catch (error) { return { mode: 'host-mirror', state: 'stale', sessionId, reason: (error as Error).message } }
const workspace = await PersistentWorkspace.peek(policy, sessionId, binding.layout.projectIdentity)
return { mode: 'host-mirror', state: await present(groupPath(binding)) ? 'interrupted' : workspace.state === 'busy' ? 'busy' : 'ready', sessionId,
revision: (await sandboxGitIdentity(policy.workspaceRoot, binding.namespace))?.id, importedAt: binding.importedAt, linkedWorktree: binding.layout.linked,
pendingMetadata: binding.initializationPending || (await sandboxGitIdentity(policy.workspaceRoot, binding.namespace))?.id !== binding.importedRevision.id, workspace }
}
async function importHost(policy: SandboxEffectivePolicy, ctx: GitHostContext, control: GitControl, draft: PersistentWorkspace): Promise<GitHostView> {
const old = await loadHostGitBinding(policy, ctx.sessionId)
if (old && await present(groupPath(old))) throw new Error('Recover the unresolved host Git commit before importing again')
await draft.assertIdle()
const snapshot = await captureHostGit(policy, control, ctx.signal)
const target = sourceDigest(snapshot)
const approval = await authorizeSandboxWrite(policy,
`导入当前项目的真实 Git 历史、分支和暂存区到本会话沙盒,并在本会话内同步该仓库的后续变更?\n历史可能包含现已删除或被工作区过滤的内容。宿主 hook、凭证 helper 和任意配置程序不执行。\n项目:${policy.workspaceRoot}\nGit:${snapshot.layout.commonDirectory}\n${snapshot.files.length} 个元数据文件;旧沙盒历史将归档保留,宿主仓库不变。`, ctx, target, false)
if (!approval) return { mode: 'host-mirror', state: old ? 'ready' : 'unimported', sessionId: ctx.sessionId, writeback: 'rejected' }
const current = await captureHostGit(policy, control, ctx.signal)
if (sourceDigest(current) !== target) throw new Error('Host Git changed while import approval was pending')
if (draft.revision && (draft.revision.policyFingerprint !== policy.fingerprint || draft.revision.workspaceIdentity !== snapshot.layout.projectIdentity)) await draft.discard()
else await draft.initialize()
await writeGitTree(control.repository, snapshot.repository)
await control.run(['fsck', '--connectivity-only', '--no-reflogs'])
await approval.consume(target)
const revision = await replaceSandboxGitRepository(policy, draft.namespace, control.repository, undefined, 'host')
const binding = newHostGitBinding(policy, ctx.sessionId, snapshot, revision)
await saveHostGitBinding(binding)
return { mode: 'host-mirror', state: 'ready', sessionId: ctx.sessionId, revision: revision.id, importedAt: binding.importedAt, linkedWorktree: binding.layout.linked, pendingMetadata: false, workspace: draft.view() }
}
function configGroups(values: GitConfigValue[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const value of values) groups.set(value.key, [...(groups.get(value.key) ?? []), value.value])
return groups
}
async function mergeConfig(control: GitControl, raw: Buffer, baseline: GitConfigValue[], next: GitConfigValue[]): Promise<Buffer> {
const old = configGroups(baseline), current = configGroups(next), path = join(control.root, 'publish-config')
await fs.writeFile(path, raw)
for (const name of new Set([...old.keys(), ...current.keys()])) {
if (JSON.stringify(old.get(name)) === JSON.stringify(current.get(name))) continue
const unset = await control.run(['config', '--no-includes', '--file', path, '--unset-all', name], { allowFailure: true })
if (unset.code !== 0 && unset.code !== 5) throw new Error('Cannot merge allowed Git configuration')
for (const value of current.get(name) ?? []) await control.run(['config', '--no-includes', '--file', path, '--add', name, value])
}
return fs.readFile(path)
}
function destinationScope(path: string, linked: boolean): 'common' | 'worktree' {
return linked && (['HEAD', 'index', 'ORIG_HEAD', 'FETCH_HEAD', 'MERGE_HEAD', 'MERGE_MSG', 'MERGE_MODE', 'AUTO_MERGE', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD', 'SQUASH_MSG', 'COMMIT_EDITMSG'].includes(path)
|| /^(?:rebase-merge|rebase-apply|sequencer|refs\/worktree)(?:\/|$)/.test(path) || path === 'logs/HEAD' || /^sharedindex\./.test(path)) ? 'worktree' : 'common'
}
function exportable(path: string): boolean {
return /^(?:HEAD|index|ORIG_HEAD|FETCH_HEAD|MERGE_HEAD|MERGE_MSG|MERGE_MODE|AUTO_MERGE|CHERRY_PICK_HEAD|REVERT_HEAD|REBASE_HEAD|SQUASH_MSG|COMMIT_EDITMSG|packed-refs|shallow|config)$/.test(path)
|| /^sharedindex\.[a-f0-9]+$/.test(path) || /^(?:objects|refs|logs|rr-cache|rebase-merge|rebase-apply|sequencer)\//.test(path)
|| /^info\/(?:exclude|attributes|sparse-checkout|refs)$/.test(path)
}
async function metadataChanges(binding: HostGitBinding, host: HostGitSnapshot, tree: Map<string, Buffer>, control: GitControl): Promise<Map<'common' | 'worktree', FileChange[]>> {
for (const name of tree.keys()) {
if (!exportable(name) || /(?:^|\/)(?:alternates|http-alternates|commondir|gitdir)$/.test(name) || name.endsWith('.promisor')) throw new Error(`Unsupported or unsafe Git metadata publication: ${name}`)
}
const safeConfig = await readSafeGitConfig(control, tree.get('config')!)
await fs.writeFile(join(control.repository, 'config'), configText(safeConfig))
await control.run(['fsck', '--connectivity-only', '--no-reflogs'])
const baseline = new Map(binding.baseline.map(entry => [entry.path, entry.hash])), result = new Map<'common' | 'worktree', FileChange[]>([['common', []], ['worktree', []]])
for (const name of new Set([...baseline.keys(), ...tree.keys()])) {
if (name === 'config' || name.startsWith('objects/') && !tree.has(name)) continue
const bytes = tree.get(name) ?? null, hash = bytes === null ? undefined : digest(bytes)
if (hash === baseline.get(name)) continue
const scope = destinationScope(name, binding.layout.linked), original = host.files.find(file => file.scope === scope && file.path === name)
if (/^objects\/(?:[a-f0-9]{2}\/|pack\/pack-[a-f0-9]+\.)/.test(name) && original && original.hash !== hash) throw new Error('Git immutable object name changed contents')
result.get(scope)!.push({ path: name, beforeHash: original?.hash ?? null, after: bytes })
}
const originalConfig = host.contents.get('common:config')
if (originalConfig) {
const merged = await mergeConfig(control, originalConfig, binding.safeConfig, safeConfig)
if (!merged.equals(originalConfig)) result.get('common')!.push({ path: 'config', beforeHash: digest(originalConfig), after: merged })
}
const priority = (entry: FileChange): number => entry.path.startsWith('objects/') ? 0 : entry.path === 'packed-refs' ? 2 : entry.path.startsWith('refs/') ? 3 : entry.path === 'HEAD' ? 4 : entry.path === 'index' ? 5 : entry.path === 'config' ? 6 : 1
for (const entries of result.values()) entries.sort((a, b) => priority(a) - priority(b) || a.path.localeCompare(b.path, 'en'))
return result
}
async function discoverGroupTransactions(group: GitGroup): Promise<Array<{ id: string; phase: string; status: string }>> {
const directory = join(sandboxConfiguration().stateRoot, 'transactions'), records = await fs.readdir(directory, { withFileTypes: true }).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error })
if (records.length > 10000) throw new Error('Too many transactions to resolve a Git group automatically')
const found: Array<{ id: string; phase: string; status: string }> = []
for (const record of records) {
if (!record.isDirectory() || !uuid.test(record.name)) continue
const path = join(directory, record.name, 'manifest.json')
if (!await present(path)) continue
const value = await workspaceRecord<{ id: string; workspaceRoot: string; workspaceIdentity: string; status: string; identity: SandboxCallIdentity }>(path, 64 * 1024 * 1024)
if (value.identity?.authorizationId !== group.authorizationId || value.identity.runId !== group.id) continue
const phase = group.phases.find(phase => phase.phase === value.identity.toolCallId && phase.root === value.workspaceRoot && phase.identity === value.workspaceIdentity)
if (!phase || value.identity.sessionId !== group.sessionId || found.some(item => item.phase === phase.phase)) throw new Error('Git group native transaction ownership is ambiguous')
found.push({ id: value.id, phase: phase.phase, status: value.status })
}
return found
}
async function saveGroup(binding: HostGitBinding, group: GitGroup): Promise<void> { await saveWorkspaceRecord(groupPath(binding), group) }
async function clearLocks(group: GitGroup): Promise<void> {
for (const lock of [...group.locks].reverse()) {
const root = lock.scope === 'common' ? group.layout.commonDirectory : group.layout.gitDirectory
const expected = lock.scope === 'common' ? group.layout.commonIdentity : group.layout.gitIdentity
const writer = await NativeWorkspaceWriter.create(root, expected, true)
try {
const entry = await writer.inspect(lock.path, true)
if (entry.kind === 'file') {
if (digest(entry.bytes!) !== lock.hash) throw new Error('Git coordination lock changed; refusing to remove it')
await writer.replace(lock.path, lock.hash, null)
} else if (entry.kind !== 'missing') throw new Error('Git coordination lock is not an owned file')
for (const parent of [...lock.parents].reverse()) {
const entry = await writer.inspect(parent.path)
if (entry.kind === 'missing') continue
if (!parent.identity || entry.kind !== 'directory' || entry.identity !== parent.identity) throw new Error('Git coordination parent identity changed; refusing cleanup')
if (!(await writer.list(parent.path)).length) await writer.rmdir(parent.path)
}
} finally { await writer.close() }
}
}
async function acquireLocks(binding: HostGitBinding, group: GitGroup, changes: Map<'common' | 'worktree', FileChange[]>): Promise<void> {
const requests = new Map<string, { scope: 'common' | 'worktree'; path: string }>()
const add = (scope: 'common' | 'worktree', path: string): void => { requests.set(`${scope}:${path}`, { scope, path }) }
add(binding.layout.linked ? 'worktree' : 'common', 'index.lock'); add(binding.layout.linked ? 'worktree' : 'common', 'HEAD.lock'); add('common', 'packed-refs.lock')
for (const [scope, entries] of changes) for (const entry of entries) if (entry.path.startsWith('refs/') || entry.path === 'config') add(scope, `${entry.path}.lock`)
const bytes = Buffer.from(`YCode Git group ${group.id}\n`)
for (const request of requests.values()) {
const root = request.scope === 'common' ? group.layout.commonDirectory : group.layout.gitDirectory
const writer = await NativeWorkspaceWriter.create(root, request.scope === 'common' ? group.layout.commonIdentity : group.layout.gitIdentity, true)
try {
const lock: GroupLock = { ...request, hash: digest(bytes), parents: [] }
group.locks.push(lock); await saveGroup(binding, group)
const parts = request.path.split('/').slice(0, -1)
for (let index = 1; index <= parts.length; index++) {
const parent = parts.slice(0, index).join('/')
if ((await writer.inspect(parent)).kind === 'missing') {
const entry: { path: string; identity?: string } = { path: parent }
lock.parents.push(entry); await saveGroup(binding, group); entry.identity = await writer.mkdir(parent); await saveGroup(binding, group)
}
}
await writer.replace(request.path, null, bytes)
} finally { await writer.close() }
}
}
async function finishCommitted(binding: HostGitBinding, group: GitGroup, draft: PersistentWorkspace, control: GitControl): Promise<HostGitBinding> {
const host = await captureHostGit(draft.policy, control, undefined, new Set(group.locks.map(lock => `${lock.scope}:${lock.path}`)))
const target = join(control.root, `committed-${randomUUID()}`)
await writeGitTree(target, host.repository)
const revision = await replaceSandboxGitRepository(draft.policy, binding.namespace, target, undefined, 'host')
if (group.transactions.worktree && draft.revision?.lastTransactionId !== group.transactions.worktree) await draft.committed(group.transactions.worktree)
const updated = { ...newHostGitBinding(draft.policy, binding.sessionId, host, revision), importedAt: binding.importedAt, lastGroupId: group.id }
await saveHostGitBinding(updated)
await clearLocks(group)
await fs.mkdir(join(hostGitRoot(binding.workspaceRoot, binding.sessionId), 'groups'), { recursive: true })
await saveWorkspaceRecord(join(hostGitRoot(binding.workspaceRoot, binding.sessionId), 'groups', `${group.id}.json`), group)
await fs.unlink(groupPath(binding))
return updated
}
async function recoverGroup(binding: HostGitBinding, draft: PersistentWorkspace, control: GitControl): Promise<GitHostView> {
const group = await workspaceRecord<GitGroup>(groupPath(binding), 16 * 1024 * 1024)
if (group.version !== 1 || !uuid.test(group.id) || group.workspaceRoot !== binding.workspaceRoot || group.sessionId !== binding.sessionId || group.policyFingerprint !== draft.policy.fingerprint
|| JSON.stringify(group.layout) !== JSON.stringify(binding.layout) || !Array.isArray(group.phases) || group.phases.length > 3 || !Array.isArray(group.locks) || group.locks.length > 10000) throw new Error('Invalid host Git group ownership')
await assertGitLayout(group.layout)
for (const phase of group.phases) {
const expected = phase.phase === 'worktree' ? [group.layout.project, group.layout.projectIdentity] : phase.phase === 'common' ? [group.layout.commonDirectory, group.layout.commonIdentity] : phase.phase === 'worktree-git' ? [group.layout.gitDirectory, group.layout.gitIdentity] : []
if (phase.root !== expected[0] || phase.identity !== expected[1] || group.phases.filter(value => value.phase === phase.phase).length !== 1) throw new Error('Git group contains an unbound phase root')
}
const transactions = await discoverGroupTransactions(group)
for (const entry of transactions) group.transactions[entry.phase] = entry.id
const allCommitted = group.phases.length > 0 && group.phases.every(phase => transactions.some(entry => entry.phase === phase.phase && entry.status === 'committed'))
if (group.status === 'committed' && !allCommitted) throw new Error('Committed Git group does not match its native transaction receipts')
const complete = (group.status === 'committed' || group.status === 'applying') && allCommitted
if (complete) {
group.status = 'committed'; await saveGroup(binding, group)
const updated = await finishCommitted(binding, group, draft, control)
return { mode: 'host-mirror', state: 'ready', sessionId: binding.sessionId, writeback: 'committed', groupId: group.id, revision: updated.importedRevision.id, workspace: draft.view() }
}
group.status = 'rolling-back'; await saveGroup(binding, group)
for (const phase of [...group.phases].reverse()) {
const transaction = transactions.find(entry => entry.phase === phase.phase)
if (transaction && transaction.status !== 'rolled-back') await restoreTransaction(transaction.id, phase.root)
}
group.status = 'rolled-back'; await saveGroup(binding, group)
await clearLocks(group)
await fs.mkdir(join(hostGitRoot(binding.workspaceRoot, binding.sessionId), 'groups'), { recursive: true })
await saveWorkspaceRecord(join(hostGitRoot(binding.workspaceRoot, binding.sessionId), 'groups', `${group.id}.json`), group)
await fs.unlink(groupPath(binding))
return { mode: 'host-mirror', state: 'ready', sessionId: binding.sessionId, writeback: 'not-applied', groupId: group.id, workspace: draft.view() }
}
async function commitHost(policy: SandboxEffectivePolicy, ctx: GitHostContext, binding: HostGitBinding, draft: PersistentWorkspace, control: GitControl): Promise<GitHostView> {
await draft.synchronize()
const host = await captureHostGit(policy, control, ctx.signal)
if (JSON.stringify(host.layout) !== JSON.stringify(binding.layout) || JSON.stringify(host.files) !== JSON.stringify(binding.source)) throw new Error('Host Git changed; synchronize the mirror before committing')
const { identity } = await copySandboxGitRepository(policy.workspaceRoot, binding.namespace, control.repository), tree = await readGitTree(control.repository)
const changes = await metadataChanges(binding, host, tree, control), files = await draft.fileChanges()
const count = files.files.length + files.directories.length + [...changes.values()].reduce((total, entries) => total + entries.length, 0)
if (!count) return { mode: 'host-mirror', state: 'ready', sessionId: ctx.sessionId, writeback: 'unchanged', workspace: draft.view() }
if (count > policy.maxChanges) throw new Error('Combined Git/worktree publication exceeds the policy change limit')
const target = digest(Buffer.from(JSON.stringify({ layout: binding.layout, source: binding.source, revision: identity, draft: draft.revision!.id,
files: fileChangeDigest(files.files, files.directories), metadata: [...changes].map(([scope, values]) => [scope, fileChangeDigest(values)]) })))
const refs = (repository: Map<string, Buffer>): Map<string, string> => {
const result = new Map<string, string>()
for (const line of repository.get('packed-refs')?.toString('utf8').split('\n') ?? []) {
const match = /^([a-f0-9]{40}|[a-f0-9]{64}) (refs\/.+)$/.exec(line)
if (match) result.set(match[2], match[1])
}
for (const [name, value] of repository) if (name === 'HEAD' || name.startsWith('refs/')) result.set(name, value.toString('utf8').trim())
return result
}
const beforeRefs = refs(host.repository), afterRefs = refs(tree)
const refPreview = [...new Set([...beforeRefs.keys(), ...afterRefs.keys()])].filter(name => beforeRefs.get(name) !== afterRefs.get(name))
.slice(0, 20).map(name => `${name}: ${beforeRefs.get(name) ?? '不存在'} -> ${afterRefs.get(name) ?? '删除'}`)
const filePreview = draft.view().changes.slice(0, 20).map(change => `${change.operation}: ${change.path}`)
const approval = await authorizeSandboxWrite(policy,
`将本会话草稿的 ${files.files.length + files.directories.length} 项项目变更和 ${count - files.files.length - files.directories.length} 项真实 Git 元数据变更保存到宿主仓库?\n包括暂存区、HEAD/分支/标签、历史对象及允许的配置数据;不会执行宿主 hook 或凭证 helper。\n项目:${policy.workspaceRoot}\n${[...refPreview, ...filePreview].join('\n')}`, ctx, target)
if (!approval) return { mode: 'host-mirror', state: 'ready', sessionId: ctx.sessionId, writeback: 'rejected', workspace: draft.view() }
await draft.assertCurrent(ctx.signal)
if ((await sandboxGitIdentity(policy.workspaceRoot, binding.namespace))?.id !== identity.id || sourceDigest(await captureHostGit(policy, control, ctx.signal)) !== sourceDigest(host)) throw new Error('Git source/candidate changed while commit approval was pending')
const phases: GroupRoot[] = []
if (files.files.length || files.directories.length) phases.push({ phase: 'worktree', root: policy.workspaceRoot, identity: binding.layout.projectIdentity })
if (changes.get('common')!.length) phases.push({ phase: 'common', root: binding.layout.commonDirectory, identity: binding.layout.commonIdentity })
if (changes.get('worktree')!.length) phases.push({ phase: 'worktree-git', root: binding.layout.gitDirectory, identity: binding.layout.gitIdentity })
const group: GitGroup = { version: 1, id: randomUUID(), sessionId: ctx.sessionId, workspaceRoot: policy.workspaceRoot, policyFingerprint: policy.fingerprint, layout: binding.layout,
authorizationId: approval.id, gitRevision: identity, workspaceRevision: draft.revision!.id, createdAt: Date.now(), status: 'prepared', phases, locks: [], transactions: {} }
await saveGroup(binding, group)
let hostCommitted = false
try {
await acquireLocks(binding, group, changes)
const ignored = new Set(group.locks.map(lock => `${lock.scope}:${lock.path}`))
if (sourceDigest(await captureHostGit(policy, control, ctx.signal, ignored)) !== sourceDigest(host)) throw new Error('Host Git changed before its coordination locks were acquired')
await approval.consume(target)
group.status = 'applying'; await saveGroup(binding, group)
for (const phase of phases) {
assertActive(ctx.signal)
const metadata = phase.phase !== 'worktree', entries = metadata ? changes.get(phase.phase === 'common' ? 'common' : 'worktree')! : files.files
const transaction = await commitChanges(phase.root, entries, { runId: group.id, toolCallId: phase.phase, sessionId: ctx.sessionId, authorizationId: approval.id, policyRevision: policy.revision, policyFingerprint: policy.fingerprint }, ctx.signal,
{ workspaceIdentity: phase.identity, gitMetadata: metadata, beforeApply: async () => { await approval.assertValid(target); await assertGitLayout(binding.layout) }, check: () => approval.assertValid(target) }, metadata ? [] : files.directories)
group.transactions[phase.phase] = transaction; await saveGroup(binding, group)
}
hostCommitted = true; group.status = 'committed'; await saveGroup(binding, group)
const updated = await finishCommitted(binding, group, draft, control)
return { mode: 'host-mirror', state: 'ready', sessionId: ctx.sessionId, writeback: 'committed', groupId: group.id, transactionId: group.transactions.worktree, revision: updated.importedRevision.id, workspace: draft.view() }
} catch (error) {
const reason = (error as Error).message
group.reason = reason
if (hostCommitted) {
await saveGroup(binding, group).catch(() => undefined)
return { mode: 'host-mirror', state: 'interrupted', sessionId: ctx.sessionId, writeback: 'committed', groupId: group.id, transactionId: group.transactions.worktree, reason }
}
try { group.status = 'rolling-back'; await saveGroup(binding, group); await recoverGroup(binding, draft, control) }
catch { group.status = 'recovery-required'; await saveGroup(binding, group).catch(() => undefined); return { mode: 'host-mirror', state: 'interrupted', sessionId: ctx.sessionId, writeback: 'unknown', groupId: group.id, reason } }
throw error
}
}
export async function gitHostAction(action: 'status' | 'init' | 'import' | 'changes' | 'commit' | 'discard' | 'recover', context: GitHostContext, expectedRevision?: string, expectedWorkspaceRevision?: string): Promise<GitHostView> {
if (!['status', 'init', 'import', 'changes', 'commit', 'discard', 'recover'].includes(action)) throw new Error('Invalid Git host action')
if (action === 'status') return hostGitStatus(context.workspaceRoot, context.sessionId)
return withSandboxExecution(context.workspaceRoot, context.signal, async root => {
const ctx = { ...context, workspaceRoot: root }, policy = await effectiveSandboxPolicy(root)
if (!policy.commandsEnabled) throw new Error('Sandbox command policy disables Git operations')
let binding = await loadHostGitBinding(policy, ctx.sessionId)
const control = await createGitControl(policy, ctx.signal)
let draft: PersistentWorkspace | undefined, outcome: GitHostView | undefined
const apply = async (): Promise<GitHostView> => {
const identity = await NativeWorkspaceWriter.create(root, undefined, true).then(async writer => { const identity = writer.rootIdentity; await writer.close(); return identity })
draft = await PersistentWorkspace.open(policy, ctx.sessionId, identity, action === 'recover' || action === 'discard')
if (expectedWorkspaceRevision !== undefined && draft.revision?.id !== expectedWorkspaceRevision) throw new Error('长期工作区版本已变化,请刷新后重试')
if (expectedRevision !== undefined && (await sandboxGitIdentity(root, draft.namespace))?.id !== expectedRevision) throw new Error('Git draft revision changed')
if (action === 'import') return await importHost(policy, ctx, control, draft)
if (action === 'init') return await newRepository(policy, ctx, draft, control)
const ownerRoot = hostGitRoot(root, ctx.sessionId), cloneExists = await present(join(ownerRoot, 'clone.json'))
if (action === 'recover' && await present(join(ownerRoot, 'initialization.json'))) {
binding = await recoverInitialization(policy, ctx, draft, control)
return { ...await hostGitStatus(root, ctx.sessionId), state: 'ready', workspace: draft.view() }
}
if (action === 'discard' && (!binding && cloneExists || binding && binding.policyFingerprint !== policy.fingerprint)) {
if (binding && await present(groupPath(binding)) || await present(join(ownerRoot, 'initialization.json'))) throw new Error('Recover the unresolved Git transaction before discarding')
await recoverSandboxGitLock(root, draft.namespace); await draft.discard(); await resetSandboxGit(root, draft.namespace)
if (binding) await fs.rename(join(ownerRoot, 'binding.json'), join(ownerRoot, `archived-binding-${randomUUID()}.json`))
if (cloneExists) await fs.rename(join(ownerRoot, 'clone.json'), join(ownerRoot, `archived-clone-${randomUUID()}.json`))
return { mode: 'host-mirror', state: 'unimported', sessionId: ctx.sessionId, writeback: 'not-applied', workspace: draft.view() }
}
if (action === 'commit' && !binding && cloneExists) {
const initializer = await createGitControl(policy, ctx.signal)
try { binding = await bootstrapHost(policy, ctx, draft, initializer) } finally { await initializer.close() }
}
if (!binding && cloneExists && ['changes', 'recover'].includes(action)) {
if (action === 'recover') { await recoverSandboxGitLock(root, draft.namespace); await draft.recoverCheckout() } else await draft.synchronize()
return { ...await hostGitStatus(root, ctx.sessionId), state: 'ready', workspace: draft.view() }
}
if (!binding) throw new Error('Import the current host repository with sandbox_git first')
assertHostGitBinding(binding, policy); await assertGitLayout(binding.layout)
if (action === 'recover') {
await recoverSandboxGitLock(root, binding.namespace)
if (await present(groupPath(binding))) return await recoverGroup(binding, draft, control)
await draft.recoverCheckout()
return { mode: 'host-mirror', state: 'ready', sessionId: ctx.sessionId, workspace: draft.view() }
}
if (action === 'discard') {
if (await present(groupPath(binding))) throw new Error('Recover host Git outcome before discarding')
await recoverSandboxGitLock(root, binding.namespace)
await draft.discard()
const host = await captureHostGit(policy, control, ctx.signal)
await writeGitTree(control.repository, host.repository)
const revision = await replaceSandboxGitRepository(policy, binding.namespace, control.repository, undefined, 'host')
await saveHostGitBinding(newHostGitBinding(policy, ctx.sessionId, host, revision))
return { mode: 'host-mirror', state: 'ready', sessionId: ctx.sessionId, revision: revision.id, writeback: 'not-applied', workspace: draft.view() }
}
const refreshed = await refreshHostGit(policy, ctx.sessionId, ctx.signal)
if (action === 'commit') return await commitHost(policy, ctx, refreshed!, draft, control)
await draft.synchronize()
return { ...await hostGitStatus(root, ctx.sessionId), state: 'ready', workspace: draft.view() }
}
try { return outcome = await apply() }
finally {
try { await draft?.close() }
catch (error) {
if (outcome) {
outcome.state = 'interrupted'
outcome.reason = [outcome.reason, `Git workspace cleanup requires maintenance: ${(error as Error).message}`].filter(Boolean).join('; ')
}
} finally { await control.close() }
}
})
}
+241
View File
@@ -0,0 +1,241 @@
import { promises as fs } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { SandboxEffectivePolicy } from '@shared/sandbox'
import { NativeWorkspaceWriter } from './filesystem-writer'
import { createGitControl, type GitControl } from './git-control'
import { sandboxConfiguration } from './configuration'
import { digest } from './transactions'
import { regularBytes } from './workspace-files'
import { saveWorkspaceRecord, sealWorkspace, workspaceKey, workspaceRecord } from './workspace'
import { copySandboxGitRepository, replaceSandboxGitRepository, type GitIdentity } from './git-repository'
import { assertActive, within } from './policy'
import { assertPolicyCurrent } from './policy-store'
export interface GitConfigValue { key: string; value: string }
export interface HostGitLayout {
project: string; projectIdentity: string; gitDirectory: string; gitIdentity: string
commonDirectory: string; commonIdentity: string; linked: boolean; gitFileHash?: string
}
export interface GitSourceFile { scope: 'common' | 'worktree'; path: string; hash: string; size: number }
export interface GitTreeFile { path: string; hash: string; size: number }
export interface HostGitBinding {
version: 1; id: string; namespace: string; sessionId: string; workspaceRoot: string; policyFingerprint: string
layout: HostGitLayout; source: GitSourceFile[]; baseline: GitTreeFile[]; safeConfig: GitConfigValue[]
importedAt: number; importedRevision: GitIdentity; lastGroupId?: string
initializationPending?: boolean
}
export interface HostGitSnapshot {
layout: HostGitLayout; files: GitSourceFile[]; contents: Map<string, Buffer>; repository: Map<string, Buffer>; safeConfig: GitConfigValue[]
}
const key = (value: string): string => value.toLowerCase()
const hashPattern = /^[a-f0-9]{64}$/
const identityPattern = /^[a-f0-9]{16}:[a-f0-9]{32}$/
const ownFiles = new Set(['HEAD', 'index', 'ORIG_HEAD', 'FETCH_HEAD', 'MERGE_HEAD', 'MERGE_MSG', 'MERGE_MODE', 'AUTO_MERGE', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD', 'SQUASH_MSG', 'COMMIT_EDITMSG'])
export const hostGitRoot = (workspace: string, sessionId: string): string => join(sandboxConfiguration().stateRoot, 'host-git', workspaceKey(workspace, sessionId))
export const sourceKey = (entry: Pick<GitSourceFile, 'scope' | 'path'>): string => `${entry.scope}:${entry.path}`
export function validateGitPath(path: string): void {
if (!path || path.length > 1024 || isAbsolute(path) || /[\\:\x00-\x1f<>"|?*]/.test(path)
|| path.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe Git metadata path')
}
function wanted(path: string, scope: 'common' | 'worktree', linked: boolean): boolean {
if (path.endsWith('.lock') || path.split('/').some(name => name.startsWith('.ycode-sandbox-'))) throw new Error('Host Git is busy or has an unresolved transaction; inspect its lock/recovery records')
if (scope === 'common' && (path === 'config' || path === 'packed-refs' || path === 'shallow' || /^(?:objects|rr-cache)(?:\/|$)/.test(path)
|| /^refs(?:\/|$)/.test(path) || /^(?:logs|logs\/refs)(?:\/|$)/.test(path)
|| /^(?:info|info\/(?:exclude|attributes|sparse-checkout|refs))$/.test(path))) return !(linked && path === 'logs/HEAD')
return (!linked || scope === 'worktree') && (ownFiles.has(path) || /^sharedindex\.[a-f0-9]+$/.test(path)
|| /^(?:rebase-merge|rebase-apply|sequencer)(?:\/|$)/.test(path) || path === 'config.worktree'
|| linked && /^(?:logs|logs\/HEAD|refs|refs\/worktree|info|info\/sparse-checkout)(?:\/|$)/.test(path))
}
function safeConfigKey(name: string): boolean {
return /^(?:user\.(?:name|email)|core\.(?:repositoryformatversion|filemode|ignorecase|autocrlf|eol|precomposeunicode|sparsecheckout|sparsecheckoutcone)|extensions\.(?:objectformat|worktreeconfig)|remote\.[^\x00-\x1f]+\.(?:url|pushurl|fetch|tagopt)|branch\.[^\x00-\x1f]+\.(?:remote|merge|rebase)|pull\.(?:rebase|ff)|push\.default)$/.test(name)
}
export function configText(values: GitConfigValue[]): Buffer {
const quote = (value: string): string => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\t', '\\t').replaceAll('\b', '\\b')}"`
const lines = ['[core]', '\tbare = false', '\tsymlinks = false']
if (!values.some(entry => entry.key === 'user.name')) lines.push('[user]', '\tname = YCode Sandbox')
if (!values.some(entry => entry.key === 'user.email')) lines.push('[user]', '\temail = sandbox@ycode.local')
for (const { key: name, value } of values) {
if (!safeConfigKey(name) || value.includes('\0') || value.length > 16384) throw new Error('Unsafe Git configuration data')
const first = name.indexOf('.'), last = name.lastIndexOf('.'), section = name.slice(0, first), variable = name.slice(last + 1)
const subsection = first === last ? '' : ` ${quote(name.slice(first + 1, last))}`
lines.push(`[${section}${subsection}]`, `\t${variable} = ${quote(value)}`)
}
return Buffer.from(lines.join('\n') + '\n')
}
export async function readSafeGitConfig(control: GitControl, bytes: Buffer): Promise<GitConfigValue[]> {
const path = join(control.root, 'config-input')
await fs.writeFile(path, bytes)
const result = await control.run(['config', '--no-includes', '--null', '--file', path, '--list'])
const values: GitConfigValue[] = []
for (const record of result.stdout.toString('utf8').split('\0').filter(Boolean)) {
const separator = record.indexOf('\n'), name = record.slice(0, separator < 0 ? undefined : separator), value = separator < 0 ? 'true' : record.slice(separator + 1)
if (name === 'extensions.refstorage' && value !== 'files') throw new Error('This Git import currently requires the files ref backend; explicitly convert reftable repositories first')
if (name === 'extensions.partialclone') throw new Error('Fully materialize partial-clone repositories first')
if (!safeConfigKey(name) || value.length > 16384) continue
if (/^remote\..*\.(?:url|pushurl)$/.test(name)) {
try { const url = new URL(value); if (url.username || url.password) continue } catch { /* SCP-style SSH URLs contain a user, not a secret. */ }
}
values.push({ key: name, value })
}
return values
}
async function layoutAt(project: string): Promise<HostGitLayout> {
const owner = await NativeWorkspaceWriter.create(project, undefined, true)
try {
let pointer = await owner.inspect('.git')
if (pointer.kind === 'missing') throw new Error('Current project root is not a Git worktree')
let gitDirectory = join(project, '.git'), gitFileHash: string | undefined
if (pointer.kind === 'file') {
pointer = await owner.inspect('.git', true)
if (!pointer.bytes || pointer.bytes.length > 4096) throw new Error('Invalid Git worktree pointer')
const match = /^gitdir: ([^\r\n\0]+)\r?\n?$/.exec(pointer.bytes.toString('utf8'))
if (!match) throw new Error('Invalid Git worktree pointer')
gitDirectory = await fs.realpath(resolve(project, match[1])); gitFileHash = digest(pointer.bytes)
}
const git = await NativeWorkspaceWriter.create(gitDirectory, undefined, true)
try {
const common = await git.inspect('commondir', true)
let commonDirectory = gitDirectory
if (common.kind === 'file') {
if (!common.bytes || common.bytes.length > 4096 || /[\r\n\0]/.test(common.bytes.toString('utf8').trim())) throw new Error('Invalid Git common-directory pointer')
commonDirectory = await fs.realpath(resolve(gitDirectory, common.bytes.toString('utf8').trim()))
} else if (common.kind !== 'missing') throw new Error('Invalid Git common-directory pointer')
const shared = await NativeWorkspaceWriter.create(commonDirectory, undefined, true)
try {
return { project, projectIdentity: owner.rootIdentity, gitDirectory, gitIdentity: git.rootIdentity, commonDirectory, commonIdentity: shared.rootIdentity,
linked: key(commonDirectory) !== key(gitDirectory), ...(gitFileHash ? { gitFileHash } : {}) }
} finally { await shared.close() }
} finally { await git.close() }
} finally { await owner.close() }
}
export async function assertGitLayout(expected: HostGitLayout): Promise<void> {
if (JSON.stringify(await layoutAt(expected.project)) !== JSON.stringify(expected)) throw new Error('Host Git/project root or linked-worktree identity changed')
}
export async function captureHostGit(policy: SandboxEffectivePolicy, control: GitControl, signal?: AbortSignal, ignoredLocks = new Set<string>()): Promise<HostGitSnapshot> {
const layout = await layoutAt(policy.workspaceRoot), files: GitSourceFile[] = [], contents = new Map<string, Buffer>(), repository = new Map<string, Buffer>()
let bytes = 0, entries = 0
for (const scope of layout.linked ? ['common', 'worktree'] as const : ['common'] as const) {
const root = scope === 'common' ? layout.commonDirectory : layout.gitDirectory
const writer = await NativeWorkspaceWriter.create(root, scope === 'common' ? layout.commonIdentity : layout.gitIdentity, true)
try {
const walk = async (path: string): Promise<void> => {
const names = path ? await writer.list(path) : await fs.readdir(root)
for (const name of names) {
assertActive(signal)
if (++entries > 30000) throw new Error('Host Git metadata exceeds 30000 entries')
const file = path ? `${path}/${name}` : name
validateGitPath(file)
if (ignoredLocks.has(`${scope}:${file}`)) continue
if (!wanted(file, scope, layout.linked)) continue
const entry = await writer.inspect(file)
if (entry.kind === 'directory') { await walk(file); continue }
if (entry.kind !== 'file') throw new Error('Git metadata changed during capture')
if (/^objects\/info\/(?:alternates|http-alternates)$/.test(file) || file.endsWith('.promisor')) throw new Error('External Git object alternates and partial-clone promises require explicit materialization before importing the repository')
const data = (await writer.inspect(file, true)).bytes!
bytes += data.length
if (bytes > 256 * 1024 * 1024) throw new Error('Host Git history exceeds 256 MiB')
files.push({ scope, path: file, hash: digest(data), size: data.length }); contents.set(`${scope}:${file}`, data)
if (file !== 'config' && file !== 'config.worktree') repository.set(file, data)
}
}
await walk('')
} finally { await writer.close() }
}
const safeConfig: GitConfigValue[] = []
for (const scope of ['common', 'worktree'] as const) for (const name of ['config', 'config.worktree']) {
const data = contents.get(`${scope}:${name}`)
if (data) safeConfig.push(...await readSafeGitConfig(control, data))
}
// The private repository is flattened; linked-worktree-only config is merged above.
const flattened = safeConfig.filter(entry => entry.key !== 'extensions.worktreeconfig')
const privateConfig = configText(flattened)
repository.set('config', privateConfig)
if (!repository.has('HEAD')) throw new Error('Host repository has no HEAD')
files.sort((a, b) => sourceKey(a).localeCompare(sourceKey(b), 'en'))
await assertPolicyCurrent(policy); await assertGitLayout(layout)
return { layout, files, contents, repository, safeConfig: await readSafeGitConfig(control, privateConfig) }
}
export function gitTree(files: Map<string, Buffer>): GitTreeFile[] {
return [...files].map(([path, bytes]) => ({ path, hash: digest(bytes), size: bytes.length })).sort((a, b) => a.path.localeCompare(b.path, 'en'))
}
export async function writeGitTree(root: string, tree: Map<string, Buffer>): Promise<void> {
await fs.mkdir(root, { recursive: true })
for (const [path, bytes] of tree) { validateGitPath(path); await fs.mkdir(dirname(join(root, path)), { recursive: true }); await fs.writeFile(join(root, path), bytes, { flag: 'wx', mode: 0o600 }) }
}
export async function readGitTree(root: string): Promise<Map<string, Buffer>> {
const tree = new Map<string, Buffer>(); let total = 0
const walk = async (path = ''): Promise<void> => {
if (path.split('/').length > 64 || tree.size > 30000) throw new Error('Git repository inventory exceeds limit')
const directory = join(root, path), stat = await fs.lstat(directory)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Git private directory cannot be a link')
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const file = path ? `${path}/${entry.name}` : entry.name; validateGitPath(file)
if (entry.isSymbolicLink()) throw new Error('Git private metadata cannot be a link')
if (entry.isDirectory()) await walk(file)
else { const bytes = await regularBytes(join(root, file), 128 * 1024 * 1024); total += bytes.length; if (total > 256 * 1024 * 1024) throw new Error('Git private repository exceeds 256 MiB'); tree.set(file, bytes) }
}
}
await walk(); return tree
}
export async function loadHostGitBinding(policy: SandboxEffectivePolicy, sessionId: string): Promise<HostGitBinding | null> {
const path = join(hostGitRoot(policy.workspaceRoot, sessionId), 'binding.json')
if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null
const value = await workspaceRecord<HostGitBinding>(path, 16 * 1024 * 1024)
if (value.version !== 1 || value.namespace !== workspaceKey(policy.workspaceRoot, sessionId) || value.sessionId !== sessionId || key(value.workspaceRoot) !== key(policy.workspaceRoot)
|| !hashPattern.test(value.policyFingerprint) || !Array.isArray(value.source) || value.source.length > 30000 || !Array.isArray(value.baseline) || value.baseline.length > 30000
|| !value.layout || key(value.layout.project) !== key(policy.workspaceRoot) || ![value.layout.projectIdentity, value.layout.gitIdentity, value.layout.commonIdentity].every(identity => identityPattern.test(identity))
|| ![value.layout.gitDirectory, value.layout.commonDirectory].every(path => isAbsolute(path) && !within(sandboxConfiguration().stateRoot, path))) throw new Error('Invalid host Git binding')
for (const entry of [...value.source, ...value.baseline]) { validateGitPath(entry.path); if (!hashPattern.test(entry.hash) || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > 128 * 1024 * 1024) throw new Error('Invalid host Git baseline file') }
return value
}
export async function saveHostGitBinding(binding: HostGitBinding): Promise<void> {
if (Buffer.byteLength(JSON.stringify(binding)) > 16 * 1024 * 1024) throw new Error('Host Git binding exceeds the 16 MiB manifest limit')
const root = hostGitRoot(binding.workspaceRoot, binding.sessionId)
const created = !await fs.lstat(root).catch(() => null)
await fs.mkdir(root, { recursive: true })
if (created) await sealWorkspace(root)
await saveWorkspaceRecord(join(root, 'binding.json'), binding)
}
export function assertHostGitBinding(binding: HostGitBinding, policy: SandboxEffectivePolicy): void {
if (binding.policyFingerprint !== policy.fingerprint) throw new Error('Host Git import belongs to an older policy; explicitly import again under the current policy')
}
/** Merge independent external metadata edits; never replace a competing index/ref. */
export async function refreshHostGit(policy: SandboxEffectivePolicy, sessionId: string, signal?: AbortSignal): Promise<HostGitBinding | null> {
const binding = await loadHostGitBinding(policy, sessionId)
if (!binding) return null
assertHostGitBinding(binding, policy)
if (await fs.lstat(join(hostGitRoot(policy.workspaceRoot, sessionId), 'commit.json')).catch(() => null)) throw new Error('Host Git has an unresolved group transaction; recover it first')
const control = await createGitControl(policy, signal)
try {
const host = await captureHostGit(policy, control, signal)
if (JSON.stringify(host.layout) !== JSON.stringify(binding.layout)) throw new Error('Host Git layout identity changed; the old mirror cannot be reused')
if (JSON.stringify(host.files) === JSON.stringify(binding.source)) return binding
const { identity } = await copySandboxGitRepository(policy.workspaceRoot, binding.namespace, control.repository)
const current = await readGitTree(control.repository), base = new Map(binding.baseline.map(entry => [entry.path, entry.hash])), next = new Map(host.repository), merged = new Map(current)
const conflicts: string[] = []
for (const name of new Set([...base.keys(), ...next.keys()])) {
const before = base.get(name), after = next.has(name) ? digest(next.get(name)!) : undefined, local = current.has(name) ? digest(current.get(name)!) : undefined
if (before === after) continue
if (local !== before && local !== after) conflicts.push(name)
if (next.has(name)) merged.set(name, next.get(name)!); else merged.delete(name)
}
if (conflicts.length) throw new Error(`Host Git metadata conflicts with the saved draft: ${conflicts.slice(0, 20).join(', ')}`)
const target = join(control.root, 'merged')
await writeGitTree(target, merged)
const revision = await replaceSandboxGitRepository(policy, binding.namespace, target, identity, 'host')
const clean = JSON.stringify(gitTree(merged)) === JSON.stringify(gitTree(host.repository))
const updated = { ...binding, source: host.files, baseline: gitTree(host.repository), safeConfig: host.safeConfig, ...(clean ? { importedRevision: revision } : {}) }
await saveHostGitBinding(updated)
return updated
} finally { await control.close() }
}
export function newHostGitBinding(policy: SandboxEffectivePolicy, sessionId: string, snapshot: HostGitSnapshot, revision: GitIdentity): HostGitBinding {
return { version: 1, id: randomUUID(), namespace: workspaceKey(policy.workspaceRoot, sessionId), sessionId, workspaceRoot: policy.workspaceRoot,
policyFingerprint: policy.fingerprint, layout: snapshot.layout, source: snapshot.files, baseline: gitTree(snapshot.repository), safeConfig: snapshot.safeConfig, importedAt: Date.now(), importedRevision: revision }
}
+228
View File
@@ -0,0 +1,228 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { withSandboxExecution } from './execution'
import { effectiveSandboxPolicy, assertPolicyCurrent } from './policy-store'
import { createGitControl, type GitControl } from './git-control'
import { authorizeSandboxWrite } from './authorizations'
import { PersistentWorkspace, saveWorkspaceRecord, sealWorkspace, workspaceRecord } from './workspace'
import { NativeWorkspaceWriter } from './filesystem-writer'
import { copySandboxGitRepository, replaceSandboxGitRepository, type GitIdentity } from './git-repository'
import { configText, hostGitRoot, loadHostGitBinding, readGitTree, readSafeGitConfig, refreshHostGit } from './git-host-source'
import { gitCredentialIdentity, gitRemoteUrl, gitRemoteEndpoint, loadGitCredential } from './git-credentials'
import { digest } from './transactions'
import type { GitHostContext } from './git-host-service'
import { verifiedBinary } from './native-component'
export interface GitRemoteRequest {
action: 'ls-remote' | 'clone' | 'fetch' | 'pull' | 'push'
remote?: string; url?: string; branch?: string; refspecs?: string[]; credentialId?: string
forceWithLease?: Record<string, string>; tags?: boolean; dryRun?: boolean
}
export interface GitRemoteResult {
operationId: string; action: GitRemoteRequest['action']; state: 'denied' | 'completed' | 'failed' | 'unknown'
remote: string; stdout?: string; stderr?: string; revision?: string; needsCheckout?: boolean; needsMerge?: boolean
intendedRefs?: Record<string, string>; writeback: 'not-applied' | 'remote-committed' | 'unknown'; reason?: string; cleanupRequired?: boolean
}
interface RemoteRecord extends Omit<GitRemoteResult, 'state'> {
version: 1; sessionId: string; workspaceRoot: string; policyFingerprint: string; createdAt: number
state: GitRemoteResult['state'] | 'pending' | 'running'; authorizationId?: string; completedAt?: number
}
const remoteName = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,100}$/
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
function ref(value: string): boolean { return /^(?:refs\/(?:heads|tags|remotes)\/)?[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(value) && !value.includes('..') && !value.includes('//') && !value.endsWith('/') && !value.endsWith('.') && !value.endsWith('.lock') }
async function transport(control: GitControl, url: URL, credentialId?: string): Promise<{ config: Record<string, string>; environment: Record<string, string>; secrets: string[] }> {
const config: Record<string, string> = { [`protocol.${url.protocol.slice(0, -1)}.allow`]: 'always', 'http.proxy': '', 'http.sslBackend': 'schannel', 'http.followRedirects': 'false', 'http.sslVerify': 'true' }
const environment: Record<string, string> = { GIT_ALLOW_PROTOCOL: url.protocol.slice(0, -1) }, secrets: string[] = []
if (url.protocol === 'ssh:' && !credentialId) throw new Error('SSH requires an explicitly imported key and pinned known_hosts credential')
if (credentialId) {
const credential = await loadGitCredential(credentialId, url)
if (credential.type === 'https') {
const token = Buffer.from(`${credential.username}:${credential.secret}`).toString('base64')
config['http.extraHeader'] = `Authorization: Basic ${token}`; secrets.push(credential.secret, token)
if (credential.caCertificate) {
const certificate = join(control.root, 'remote-ca.crt'); await fs.writeFile(certificate, credential.caCertificate)
config['http.sslCAInfo'] = certificate; config['http.schannelUseSSLCAInfo'] = 'true'
}
} else {
const key = join(control.root, 'ssh-key'), hosts = join(control.root, 'known-hosts')
await fs.writeFile(key, credential.privateKey, { mode: 0o600 }); await fs.writeFile(hosts, credential.knownHosts, { mode: 0o600 })
await fs.writeFile(join(control.root, 'ssh-config'), '')
const proxy = join(control.root, 'tools/git/git-ssh.exe')
await fs.copyFile(await verifiedBinary(true, true, true), proxy)
environment.GIT_SSH = proxy; environment.GIT_SSH_VARIANT = 'ssh'
secrets.push(credential.privateKey)
}
}
return { config, environment, secrets }
}
export async function gitRemoteOperation(context: GitHostContext, operationId: string): Promise<GitRemoteResult> {
if (!uuid.test(operationId)) throw new Error('Invalid Git remote operation ID')
const policy = await effectiveSandboxPolicy(context.workspaceRoot), path = join(hostGitRoot(policy.workspaceRoot, context.sessionId), 'remote', `${operationId}.json`)
const record = await workspaceRecord<RemoteRecord>(path, 40 * 1024 * 1024)
if (record.version !== 1 || record.operationId !== operationId || record.sessionId !== context.sessionId || record.workspaceRoot !== policy.workspaceRoot) throw new Error('Git remote operation owner mismatch')
return { ...record, state: ['pending', 'running'].includes(record.state) ? 'unknown' : record.state as GitRemoteResult['state'] }
}
/** Only fixed Git transport operations cross this trusted host boundary. */
export async function gitRemoteAction(request: GitRemoteRequest, context: GitHostContext): Promise<GitRemoteResult> {
if (!request || typeof request !== 'object' || Array.isArray(request)) throw new Error('Invalid Git remote request')
if (Object.keys(request).some(name => !['action', 'remote', 'url', 'branch', 'refspecs', 'credentialId', 'forceWithLease', 'tags', 'dryRun'].includes(name))) throw new Error('Git remote does not accept executable, environment or owner overrides')
for (const name of ['remote', 'url', 'branch', 'credentialId'] as const) if (request[name] !== undefined && typeof request[name] !== 'string') throw new Error('Invalid Git remote argument type')
if (request.tags !== undefined && typeof request.tags !== 'boolean' || request.dryRun !== undefined && typeof request.dryRun !== 'boolean'
|| request.forceWithLease !== undefined && (!request.forceWithLease || typeof request.forceWithLease !== 'object' || Array.isArray(request.forceWithLease) || Object.values(request.forceWithLease).some(value => typeof value !== 'string'))) throw new Error('Invalid Git remote flags')
if (!['ls-remote', 'clone', 'fetch', 'pull', 'push'].includes(request.action) || request.refspecs && (request.refspecs.length > 64 || request.refspecs.some(value => typeof value !== 'string' || value.length > 1024))) throw new Error('Invalid Git remote operation')
return withSandboxExecution(context.workspaceRoot, context.signal, async root => {
const policy = await effectiveSandboxPolicy(root), ctx = { ...context, workspaceRoot: root }
if (!policy.commandsEnabled) throw new Error('Sandbox command policy disables Git transport')
const control = await createGitControl(policy, ctx.signal)
let draft: PersistentWorkspace | undefined, record: RemoteRecord | undefined, recordPath: string | undefined, networkStarted = false, secrets: string[] = []
const redact = (value: string): string => secrets.reduce((text, secret) => secret ? text.replaceAll(secret, '[redacted]') : text, value)
try {
const writer = await NativeWorkspaceWriter.create(root, undefined, true), rootIdentity = writer.rootIdentity; await writer.close()
draft = await PersistentWorkspace.open(policy, ctx.sessionId, rootIdentity)
await draft.synchronize()
const binding = await refreshHostGit(policy, ctx.sessionId, ctx.signal)
let identity: GitIdentity | undefined, origin: 'host' | 'remote' | 'empty' | undefined
if (request.action === 'clone') {
if (binding || draft.revision!.tree.length || await fs.lstat(join(hostGitRoot(root, ctx.sessionId), 'clone.json')).catch(() => null)) throw new Error('Clone requires an empty project draft without an imported repository')
await control.run(['init', '--template=' + join(control.root, 'tools/git/empty-template'), '--initial-branch=main'])
origin = 'remote'
} else {
try {
const copied = await copySandboxGitRepository(root, draft.namespace, control.repository); identity = copied.identity; origin = copied.manifest.origin
if (origin === 'host' && !binding) throw new Error('Host Git import publication was interrupted; explicitly import again before use')
}
catch (error) {
if (request.action !== 'ls-remote' || !/has no saved repository/.test((error as Error).message)) throw error
await control.run(['init', '--template=' + join(control.root, 'tools/git/empty-template'), '--initial-branch=main'])
}
}
const tree = await readGitTree(control.repository)
if ([...tree.keys()].some(path => /(?:^|\/)(?:alternates|http-alternates|commondir|gitdir)$/.test(path) || path.endsWith('.promisor'))) throw new Error('Remote control refuses external object paths and partial-clone promises')
const config = await readSafeGitConfig(control, tree.get('config')!)
await fs.writeFile(join(control.repository, 'config'), configText(config))
if (request.action === 'pull' && !request.branch) {
const current = await control.run(['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true })
if (current.code) throw new Error('Pull from detached HEAD requires an explicit branch')
const branch = current.stdout.toString().trim(), merge = config.find(entry => entry.key === `branch.${branch}.merge`)?.value
request = { ...request, branch: merge?.replace(/^refs\/heads\//, '') ?? branch, remote: request.remote ?? config.find(entry => entry.key === `branch.${branch}.remote`)?.value ?? 'origin' }
}
const name = request.remote ?? 'origin'
if (!remoteName.test(name)) throw new Error('Invalid Git remote name')
const urlInput = request.url ?? config.find(value => value.key === `remote.${name}.${request.action === 'push' ? 'pushurl' : 'url'}`)?.value ?? config.find(value => value.key === `remote.${name}.url`)?.value
if (!urlInput) throw new Error('Git remote URL is not configured')
const url = gitRemoteUrl(urlInput), endpoint = gitRemoteEndpoint(urlInput, url)
const helper = url.protocol === 'ssh:' ? 'ssh.exe' : url.protocol === 'https:' ? 'git-remote-https.exe' : 'git-remote-http.exe'
if (!await fs.lstat(join(control.root, 'tools/git', helper)).catch(() => null)) throw new Error('The selected Git runtime does not include this remote transport helper')
if (request.branch !== undefined && !ref(request.branch)) throw new Error('Invalid Git remote branch')
const credentialIdentity = request.credentialId ? await gitCredentialIdentity(request.credentialId, url) : undefined
const intendedRefs: Record<string, string> = {}, pushSpecs: string[] = []
if (request.action === 'push') {
const specs = request.refspecs ?? [`HEAD:refs/heads/${request.branch ?? 'main'}`]
for (const spec of specs) {
const parts = spec.split(':')
if (parts.length !== 2 || parts[0] && !ref(parts[0]) || !/^refs\/(?:heads|tags)\//.test(parts[1]) || !ref(parts[1]) || Object.hasOwn(intendedRefs, parts[1])) throw new Error('Push requires unique explicit source:refs/heads|tags/destination refspecs')
const object = parts[0] ? (await control.run(['rev-parse', '--verify', '--end-of-options', parts[0]])).stdout.toString().trim() : ''
if (object && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(object)) throw new Error('Invalid resolved push object')
intendedRefs[parts[1]] = object; pushSpecs.push(`${object}:${parts[1]}`)
}
for (const [name, expected] of Object.entries(request.forceWithLease ?? {})) if (!Object.hasOwn(intendedRefs, name) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(expected)) throw new Error('Force-with-lease must name an intended destination and its exact observed object ID')
}
const target = digest(Buffer.from(JSON.stringify({ request, endpoint, credentialIdentity, identity, revision: draft.revision!.id, rootIdentity, intendedRefs })))
const ownerRoot = hostGitRoot(root, ctx.sessionId), ownerCreated = !await fs.lstat(ownerRoot).catch(() => null)
await fs.mkdir(ownerRoot, { recursive: true }); if (ownerCreated) await sealWorkspace(ownerRoot)
await fs.mkdir(join(ownerRoot, 'remote'), { recursive: true })
record = { version: 1, operationId: randomUUID(), sessionId: ctx.sessionId, workspaceRoot: root, policyFingerprint: policy.fingerprint, createdAt: Date.now(), action: request.action, remote: endpoint, state: 'pending', writeback: 'not-applied', ...(request.action === 'push' ? { intendedRefs } : {}) }
recordPath = join(ownerRoot, 'remote', `${record.operationId}.json`)
await saveWorkspaceRecord(recordPath, record, false)
const approval = await authorizeSandboxWrite(policy,
`允许 Git ${request.action}${request.dryRun ? '(dry-run)' : ''} 访问指定远程?\n目标:${endpoint}\n${request.action === 'push' ? Object.entries(intendedRefs).map(([ref, object]) => `${ref} <- ${object || '删除引用'}`).join('\n') : '只从该目标读取仓库数据;项目和 Git 元数据保存在会话草稿。'}${request.forceWithLease ? '\n包含绑定明确旧引用的 force-with-lease。' : ''}\n普通命令仍保持禁网;不会使用宿主凭证 helper、SSH agent 或全局配置。`, ctx, target, request.action === 'push' && !request.dryRun)
if (!approval) { record.state = 'denied'; record.completedAt = Date.now(); await saveWorkspaceRecord(recordPath, record); return record as GitRemoteResult }
if (identity && (await copySandboxGitRepository(root, draft.namespace, join(control.root, 'approved-copy'))).identity.id !== identity.id) throw new Error('Git candidate changed while remote approval was pending')
if (request.credentialId && await gitCredentialIdentity(request.credentialId, url) !== credentialIdentity) throw new Error('Git credential changed while approval was pending')
await draft.assertCurrent(ctx.signal); await approval.consume(target)
const options = await transport(control, url, request.credentialId); secrets = options.secrets
record.authorizationId = approval.id; record.state = 'running'; await saveWorkspaceRecord(recordPath, record)
networkStarted = true
const network = { network: true, config: options.config, environment: options.environment, allowFailure: true }
let result: { code: number; stdout: Buffer; stderr: string }, emptyClone = false
if (request.action === 'ls-remote') result = await control.run(['ls-remote', '--symref', '--', endpoint, ...(request.branch ? [`refs/heads/${request.branch.replace(/^refs\/heads\//, '')}`] : [])], network)
else if (request.action === 'push') {
const leases = Object.entries(request.forceWithLease ?? {}).map(([name, expected]) => `--force-with-lease=${name}:${expected}`)
result = await control.run(['push', '--porcelain', '--atomic', '--no-verify', '--recurse-submodules=no', ...(request.dryRun ? ['--dry-run'] : []), ...leases, '--', endpoint, ...pushSpecs], network)
} else {
if (request.action === 'clone') {
const advertised = await control.run(['ls-remote', '--symref', '--', endpoint, 'HEAD'], network)
if (advertised.code) throw new Error(`Git clone advertisement failed: ${advertised.stderr}`)
const match = /^ref: refs\/heads\/([^\t\r\n]+)\tHEAD$/m.exec(advertised.stdout.toString())
const head = /^([a-f0-9]{40}|[a-f0-9]{64})\tHEAD$/m.exec(advertised.stdout.toString())
emptyClone = !head && !request.branch
if (head?.[1].length === 64) {
if (join(control.root, 'repository') !== control.repository) throw new Error('Invalid private Git control root')
await fs.rename(control.repository, join(control.root, 'initial-sha1-repository')); await fs.mkdir(control.repository)
await control.run(['init', '--object-format=sha256', '--template=' + join(control.root, 'tools/git/empty-template'), '--initial-branch=main'])
}
const branch = request.branch ?? match?.[1] ?? 'main'
if (!ref(branch)) throw new Error('Remote default branch is invalid')
request = { ...request, branch }
await control.run(['remote', 'add', name, endpoint])
await control.run(['config', `branch.${branch}.remote`, name]); await control.run(['config', `branch.${branch}.merge`, `refs/heads/${branch}`])
}
const branch = request.branch?.replace(/^refs\/heads\//, '')
const specs = request.refspecs ?? (branch && request.action !== 'clone' ? [`+refs/heads/${branch}:refs/remotes/${name}/${branch}`] : [`+refs/heads/*:refs/remotes/${name}/*`])
for (const spec of specs) if (!/^\+?refs\/(?:heads|tags)\/[a-zA-Z0-9._/*-]+:refs\/(?:remotes|tags)\/[a-zA-Z0-9._/*-]+$/.test(spec) || spec.includes('..') || spec.includes('//')) throw new Error('Fetch refspec escapes the remote-tracking/tag namespace')
result = await control.run(['fetch', '--atomic', '--no-recurse-submodules', '--no-auto-maintenance', ...(request.tags ? ['--tags'] : []), '--', endpoint, ...specs], network)
if (!result.code && request.action === 'clone' && !emptyClone) {
await control.run(['symbolic-ref', 'HEAD', `refs/heads/${branch}`])
const object = (await control.run(['rev-parse', '--verify', `refs/remotes/${name}/${branch}`])).stdout.toString().trim()
await control.run(['update-ref', `refs/heads/${branch}`, object])
await control.run(['symbolic-ref', `refs/remotes/${name}/HEAD`, `refs/remotes/${name}/${branch}`])
}
}
record.stdout = redact(result.stdout.toString('utf8')); record.stderr = redact(result.stderr)
if (result.code !== 0) throw new Error(`Git ${request.action} failed (${result.code}): ${result.stderr}\n${result.stdout.toString('utf8').slice(-4096)}`)
record.writeback = request.action === 'push' && !request.dryRun ? 'remote-committed' : 'not-applied'
if (['clone', 'fetch', 'pull'].includes(request.action)) {
await assertPolicyCurrent(policy); await draft.assertCurrent(ctx.signal)
const revision = await replaceSandboxGitRepository(policy, draft.namespace, control.repository, identity, origin)
record.revision = revision.id; record.needsCheckout = request.action === 'clone' && !emptyClone; record.needsMerge = request.action === 'pull'
if (request.action === 'clone') await saveWorkspaceRecord(join(ownerRoot, 'clone.json'), { version: 1, sessionId: ctx.sessionId, workspaceRoot: root, rootIdentity, policyFingerprint: policy.fingerprint, revision, remote: endpoint, createdAt: Date.now() })
}
record.state = 'completed'; record.completedAt = Date.now(); await saveWorkspaceRecord(recordPath, record)
return record as GitRemoteResult
} catch (error) {
const reason = redact((error as Error).message)
if (!record || !recordPath) throw error
record.state = networkStarted && request.action === 'push' && !request.dryRun ? 'unknown' : 'failed'
record.writeback = record.state === 'unknown' ? 'unknown' : 'not-applied'; record.reason = reason; record.completedAt = Date.now()
await saveWorkspaceRecord(recordPath, record).catch(() => undefined)
return record as GitRemoteResult
} finally {
try { await draft?.close() }
catch (error) {
if (record && recordPath) {
record.cleanupRequired = true
record.reason = [record.reason, `Git workspace cleanup requires maintenance: ${(error as Error).message}`].filter(Boolean).join('; ')
await saveWorkspaceRecord(recordPath, record).catch(() => undefined)
}
} finally { await control.close() }
}
})
}
/** Confirm the requested remote state after an unknown push; never replay the write. */
export async function reconcileGitRemote(context: GitHostContext, operationId: string, credentialId?: string): Promise<GitRemoteResult> {
const original = await gitRemoteOperation(context, operationId)
if (original.action !== 'push' || original.state !== 'unknown' || !original.intendedRefs) throw new Error('Only an unknown push with recorded intended refs can be reconciled')
const observed = await gitRemoteAction({ action: 'ls-remote', url: original.remote, credentialId }, context)
if (observed.state !== 'completed') return { ...original, reason: 'Remote state could not be observed; the original push remains unknown' }
const refs = new Map((observed.stdout ?? '').split('\n').map(line => line.trim().split('\t')).filter(parts => parts.length === 2).map(parts => [parts[1], parts[0]]))
const matches = Object.entries(original.intendedRefs).every(([name, object]) => (refs.get(name) ?? '') === object)
const result = { ...original, ...(matches ? { state: 'completed' as const, writeback: 'remote-committed' as const } : {}),
reason: matches ? 'Reconciled by observing the current remote refs; this does not establish the original acknowledgement or event timing.' : 'Current remote refs differ from the intended state; the original push remains unknown.' }
const policy = await effectiveSandboxPolicy(context.workspaceRoot), path = join(hostGitRoot(policy.workspaceRoot, context.sessionId), 'remote', `${operationId}.json`)
const record = await workspaceRecord<RemoteRecord>(path, 40 * 1024 * 1024)
await saveWorkspaceRecord(path, { ...record, ...result, reconciliationOperationId: observed.operationId, reconciledAt: Date.now() })
return result
}
+311
View File
@@ -0,0 +1,311 @@
import { promises as fs } from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import { deflate } from 'node:zlib'
import { promisify } from 'node:util'
import { dirname, isAbsolute, join, relative } from 'node:path'
import type { SandboxEffectivePolicy, SandboxGitState } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { assertActive, protectedPath, within } from './policy'
import { assertPolicyCurrent } from './policy-store'
import { sealWorkspace } from './workspace'
interface GitFile { path: string; bytes: number; sha256: string }
export interface GitIdentity { id: string; manifestSha256: string }
export interface GitManifest {
version: 1; id: string; workspaceRoot: string; policyFingerprint: string
createdAt: number; contentHash: string; files: GitFile[]; bytes: number
origin?: 'host' | 'remote' | 'empty'
}
export interface SandboxGitSession {
state: SandboxGitState
capture(signal?: AbortSignal): Promise<GitIdentity | null>
assertCurrent(): Promise<void>
publish(candidate: GitIdentity): Promise<void>
close(): Promise<void>
}
const limits = { files: 30_000, bytes: 256 * 1024 * 1024, fileBytes: 128 * 1024 * 1024, manifestBytes: 8 * 1024 * 1024 }
const hash = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex')
const sha1 = (bytes: Buffer): Buffer => createHash('sha1').update(bytes).digest()
const compress = promisify(deflate)
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
const hashPattern = /^[a-f0-9]{64}$/
const key = (value: string): string => process.platform === 'win32' ? value.toLowerCase() : value
const repositoryRoot = (workspace: string, namespace?: string): string => {
if (namespace !== undefined && !hashPattern.test(namespace)) throw new Error('Invalid sandbox Git draft namespace')
return join(sandboxConfiguration().stateRoot, 'git', hash(key(workspace)) + (namespace ? `-${namespace}` : ''))
}
async function directory(path: string): Promise<void> {
const stat = await fs.lstat(path)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Sandbox Git state directory must not be a link')
}
async function regular(path: string, maximum = limits.fileBytes): Promise<Buffer> {
const stat = await fs.lstat(path)
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error('Sandbox Git metadata contains a link, special file or oversized file')
const handle = await fs.open(path, 'r')
try {
const before = await handle.stat()
if (before.ino !== stat.ino || before.dev !== stat.dev || before.nlink !== 1 || before.size !== stat.size) throw new Error('Sandbox Git file was replaced before reading')
const bytes = await handle.readFile(), after = await handle.stat()
if (bytes.length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) throw new Error('Sandbox Git file changed during reading')
return bytes
} finally { await handle.close() }
}
function filePath(value: unknown): string {
if (typeof value !== 'string' || !value || value.length > 2048 || isAbsolute(value) || value.includes('\\') || value.split('/').some(part => !part || part === '.' || part === '..') || protectedPath(value)
|| /(?:^|\/)(?:commondir|gitdir|alternates|http-alternates)$/.test(value) || /\.lock$/i.test(value)) throw new Error('Unsupported or unsafe sandbox Git metadata path')
return value
}
async function prepareRoot(workspace: string, namespace?: string): Promise<string> {
const root = repositoryRoot(workspace, namespace)
await fs.mkdir(join(root, 'objects'), { recursive: true, mode: 0o700 })
for (const path of [sandboxConfiguration().stateRoot, join(sandboxConfiguration().stateRoot, 'git'), root, join(root, 'objects')]) await directory(path)
return root
}
async function activeIdentity(root: string): Promise<GitIdentity | null> {
const path = join(root, 'active.json')
if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null
const value = JSON.parse((await regular(path, 4096)).toString('utf8')) as GitIdentity
if (!uuid.test(value.id) || !hashPattern.test(value.manifestSha256)) throw new Error('Invalid sandbox Git revision identity')
return value
}
async function manifestAt(root: string, identity: GitIdentity, workspace: string): Promise<GitManifest> {
const object = join(root, 'objects', identity.id)
await directory(object); await directory(join(object, 'files'))
const bytes = await regular(join(object, 'manifest.json'), limits.manifestBytes)
if (hash(bytes) !== identity.manifestSha256) throw new Error('Sandbox Git manifest digest mismatch')
const value = JSON.parse(bytes.toString('utf8')) as GitManifest
if (value.version !== 1 || value.id !== identity.id || typeof value.workspaceRoot !== 'string' || key(value.workspaceRoot) !== key(workspace) || !hashPattern.test(value.policyFingerprint)
|| value.origin !== undefined && !['host', 'remote', 'empty'].includes(value.origin)
|| !Array.isArray(value.files) || !value.files.length || value.files.length > limits.files) throw new Error('Invalid sandbox Git manifest ownership or format')
let total = 0
const seen = new Set<string>()
for (const file of value.files) {
const path = filePath(file.path)
if (seen.has(key(path)) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > limits.fileBytes || !hashPattern.test(file.sha256)) throw new Error('Invalid sandbox Git file identity')
seen.add(key(path)); total += file.bytes
}
if (!seen.has(key('HEAD')) || !seen.has('config') || value.bytes !== total || total > limits.bytes || value.contentHash !== hash(JSON.stringify(value.files))) throw new Error('Invalid sandbox Git metadata inventory')
return value
}
async function scanRepository(source: string, signal?: AbortSignal): Promise<{ files: GitFile[]; bytes: number }> {
const files: GitFile[] = []
let bytes = 0, entries = 0
async function walk(path: string, depth = 0): Promise<void> {
if (depth > 64) throw new Error('Sandbox Git metadata nesting exceeds the limit')
await directory(path)
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
assertActive(signal)
if (++entries > limits.files) throw new Error('Sandbox Git metadata exceeds the entry limit')
const absolute = join(path, entry.name), file = filePath(relative(source, absolute).replaceAll('\\', '/'))
if (entry.isSymbolicLink()) throw new Error('Sandbox Git metadata cannot contain links')
if (entry.isDirectory()) await walk(absolute, depth + 1)
else {
const content = await regular(absolute)
bytes += content.length
if (bytes > limits.bytes) throw new Error('Sandbox Git history exceeds the 256 MiB limit')
files.push({ path: file, bytes: content.length, sha256: hash(content) })
}
}
}
await walk(source)
files.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))
return { files, bytes }
}
async function storeRepository(root: string, source: string, policy: SandboxEffectivePolicy, inventory: { files: GitFile[]; bytes: number }, signal?: AbortSignal, origin?: 'host' | 'remote' | 'empty'): Promise<GitIdentity> {
const id = randomUUID(), object = join(root, 'objects', id), target = join(object, 'files')
await fs.mkdir(target, { recursive: true, mode: 0o700 })
const manifest: GitManifest = { version: 1, id, workspaceRoot: policy.workspaceRoot, policyFingerprint: policy.fingerprint, createdAt: Date.now(), contentHash: hash(JSON.stringify(inventory.files)), ...(origin ? { origin } : {}), ...inventory }
for (const file of inventory.files) {
assertActive(signal)
const content = await regular(join(source, file.path))
if (content.length !== file.bytes || hash(content) !== file.sha256) throw new Error('Sandbox Git metadata changed after capture')
const destination = join(target, file.path)
await fs.mkdir(dirname(destination), { recursive: true })
await fs.writeFile(destination, content, { flag: 'wx', mode: 0o600 })
}
const bytes = Buffer.from(JSON.stringify(manifest) + '\n')
if (bytes.length > limits.manifestBytes) throw new Error('Sandbox Git manifest exceeds the limit')
const handle = await fs.open(join(object, 'manifest.json'), 'wx', 0o600)
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
return { id, manifestSha256: hash(bytes) }
}
async function publishIdentity(root: string, identity: GitIdentity): Promise<void> {
const temporary = join(root, `active-${randomUUID()}.tmp`)
try {
const handle = await fs.open(temporary, 'wx', 0o600)
try { await handle.writeFile(JSON.stringify(identity) + '\n'); await handle.sync() } finally { await handle.close() }
await fs.rename(temporary, join(root, 'active.json'))
} finally { await fs.unlink(temporary).catch(() => undefined) }
}
/** Build one synthetic commit from approved input bytes; never open the host .git. */
async function initializeRepository(stage: string, files: Map<string, { hash: string; size: number }>, signal?: AbortSignal): Promise<void> {
const target = join(stage, 'repository'), objects = new Set<string>()
await fs.mkdir(join(target, 'objects'), { recursive: true })
const put = async (name: string, bytes: Buffer | string): Promise<void> => {
const path = join(target, name)
await fs.mkdir(dirname(path), { recursive: true })
await fs.writeFile(path, bytes, { flag: 'wx', mode: 0o600 })
}
const object = async (type: string, bytes: Buffer): Promise<Buffer> => {
const content = Buffer.concat([Buffer.from(`${type} ${bytes.length}\0`), bytes]), id = sha1(content), hex = id.toString('hex')
if (!objects.has(hex)) { objects.add(hex); await put(`objects/${hex.slice(0, 2)}/${hex.slice(2)}`, await compress(content)) }
return id
}
interface Tree { files: Map<string, Buffer>; directories: Map<string, Tree> }
const tree = (): Tree => ({ files: new Map(), directories: new Map() }), root = tree()
const entries: Buffer[] = []
for (const [path, expected] of [...files].sort((a, b) => Buffer.compare(Buffer.from(a[0].replaceAll('\\', '/')), Buffer.from(b[0].replaceAll('\\', '/'))))) {
assertActive(signal)
const bytes = await regular(join(stage, 'workspace', path))
if (hash(bytes) !== expected.hash || bytes.length !== expected.size) throw new Error('Git baseline input changed')
const id = await object('blob', bytes), name = path.replaceAll('\\', '/')
const parts = name.split('/'); let parent = root
for (const part of parts.slice(0, -1)) { if (!parent.directories.has(part)) parent.directories.set(part, tree()); parent = parent.directories.get(part)! }
parent.files.set(parts.at(-1)!, id)
const nameBytes = Buffer.from(name), entrySize = 62 + nameBytes.length + 1
const entry = Buffer.alloc(Math.ceil(entrySize / 8) * 8)
entry.writeUInt32BE(0o100644, 24); entry.writeUInt32BE(bytes.length, 36); id.copy(entry, 40)
entry.writeUInt16BE(Math.min(nameBytes.length, 0xfff), 60); nameBytes.copy(entry, 62)
entries.push(entry)
}
const writeTree = async (value: Tree): Promise<Buffer> => {
const entries: Array<{ name: string; mode: string; id: Buffer }> = [...value.files].map(([name, id]) => ({ name, mode: '100644', id }))
for (const [name, child] of value.directories) entries.push({ name, mode: '40000', id: await writeTree(child) })
entries.sort((a, b) => Buffer.compare(Buffer.from(a.name + (a.mode === '40000' ? '/' : '')), Buffer.from(b.name + (b.mode === '40000' ? '/' : ''))))
return object('tree', Buffer.concat(entries.map(entry => Buffer.concat([Buffer.from(`${entry.mode} ${entry.name}\0`), entry.id]))))
}
const rootTree = await writeTree(root), timestamp = Math.floor(Date.now() / 1000)
const commit = await object('commit', Buffer.from(`tree ${rootTree.toString('hex')}\nauthor YCode Sandbox <sandbox@ycode.local> ${timestamp} +0000\ncommitter YCode Sandbox <sandbox@ycode.local> ${timestamp} +0000\n\nYCode sandbox baseline (filtered workspace snapshot; no host history)\n`))
const header = Buffer.alloc(12); header.write('DIRC'); header.writeUInt32BE(2, 4); header.writeUInt32BE(entries.length, 8)
const index = Buffer.concat([header, ...entries])
await put('index', Buffer.concat([index, sha1(index)]))
await put('HEAD', 'ref: refs/heads/ycode-sandbox\n')
await put('refs/heads/ycode-sandbox', `${commit.toString('hex')}\n`)
// The baseline records snapshot bytes, rather than host clean/smudge rules.
await put('info/attributes', '* -text -filter -ident -eol -working-tree-encoding\n')
await put('config', '[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tfilemode = false\n\tautocrlf = false\n\tignorecase = true\n\tsymlinks = false\n[user]\n\tname = YCode Sandbox\n\temail = sandbox@ycode.local\n')
}
export async function stageSandboxGit(stage: string, policy: SandboxEffectivePolicy, files: Map<string, { hash: string; size: number }>, signal?: AbortSignal, namespace?: string): Promise<SandboxGitSession> {
const root = await prepareRoot(policy.workspaceRoot, namespace), lockPath = join(root, 'repository.lock')
const lock = await fs.open(lockPath, 'wx', 0o600).catch(error => { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Sandbox Git is in use; inspect repository.lock if the previous host exited'); throw error })
const close = async (): Promise<void> => { await lock.close(); await fs.unlink(lockPath) }
try {
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
let identity = await activeIdentity(root), initialized = false
const target = join(stage, 'repository')
if (!identity) {
await initializeRepository(stage, files, signal)
identity = await storeRepository(root, target, policy, await scanRepository(target, signal), signal)
await assertPolicyCurrent(policy)
await publishIdentity(root, identity)
initialized = true
} else {
const manifest = await manifestAt(root, identity, policy.workspaceRoot)
if (manifest.policyFingerprint !== policy.fingerprint) throw new Error('Sandbox Git policy changed; inspect git-state and explicitly git-reset before reusing history')
await fs.mkdir(join(target, 'objects'), { recursive: true }); await fs.mkdir(join(target, 'refs'), { recursive: true })
const source = join(root, 'objects', identity.id, 'files'), checked = new Set<string>([source])
for (const file of manifest.files) {
assertActive(signal)
const path = join(source, file.path); let parent = dirname(path)
while (!checked.has(parent)) { if (!within(source, parent)) throw new Error('Sandbox Git path escaped state'); await directory(parent); checked.add(parent); parent = dirname(parent) }
const bytes = await regular(path)
if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error('Sandbox Git metadata digest mismatch')
await fs.mkdir(dirname(join(target, file.path)), { recursive: true })
await fs.writeFile(join(target, file.path), bytes, { flag: 'wx', mode: 0o600 })
}
}
const before = identity, manifest = await manifestAt(root, before, policy.workspaceRoot)
const mode = manifest.origin === 'host' ? 'host-mirror' : manifest.origin === 'remote' ? 'remote-clone' : manifest.origin === 'empty' ? 'new-repository' : 'private-snapshot'
const state: SandboxGitState = { mode, baseline: ({ 'host-mirror': 'host-repository', 'remote-clone': 'remote-repository', 'new-repository': 'empty-repository', 'private-snapshot': 'filtered-workspace' } as const)[mode], initialized, revision: before.id, manifestSha256: before.manifestSha256, persistence: 'not-applied', ...(namespace ? { scope: ({ 'host-mirror': 'host-draft', 'remote-clone': 'clone-draft', 'new-repository': 'new-draft', 'private-snapshot': 'draft' } as const)[mode] } : {}) }
const assertCurrent = async (): Promise<void> => {
assertActive(signal); await assertPolicyCurrent(policy)
const current = await activeIdentity(root)
if (current?.id !== before.id || current.manifestSha256 !== before.manifestSha256) throw new Error('Sandbox Git revision changed during execution or approval')
}
return { state, assertCurrent, close,
async capture(signal) {
const inventory = await scanRepository(target, signal)
if (hash(JSON.stringify(inventory.files)) === manifest.contentHash) return null
return storeRepository(root, target, policy, inventory, signal, manifest.origin)
},
async publish(candidate) {
await assertCurrent()
await manifestAt(root, candidate, policy.workspaceRoot)
await publishIdentity(root, candidate)
state.revision = candidate.id; state.manifestSha256 = candidate.manifestSha256; state.persistence = 'saved'
}
}
} catch (error) { await close(); throw error }
}
/** Trusted control-plane copy; validates every immutable source before publishing it. */
export async function copySandboxGitRepository(workspace: string, namespace: string, destination: string): Promise<{ identity: GitIdentity; manifest: GitManifest }> {
const root = await prepareRoot(workspace, namespace), lockPath = join(root, 'repository.lock'), lock = await fs.open(lockPath, 'wx', 0o600)
try {
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
const identity = await activeIdentity(root)
if (!identity) throw new Error('Sandbox Git has no saved repository')
const manifest = await manifestAt(root, identity, workspace)
await fs.mkdir(destination, { recursive: true })
await fs.mkdir(join(destination, 'objects'), { recursive: true }); await fs.mkdir(join(destination, 'refs'), { recursive: true })
for (const file of manifest.files) {
const bytes = await regular(join(root, 'objects', identity.id, 'files', file.path))
if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error('Sandbox Git saved repository was changed')
await fs.mkdir(dirname(join(destination, file.path)), { recursive: true })
await fs.writeFile(join(destination, file.path), bytes, { flag: 'wx', mode: 0o600 })
}
return { identity, manifest }
} finally { await lock.close(); await fs.unlink(lockPath) }
}
export async function replaceSandboxGitRepository(policy: SandboxEffectivePolicy, namespace: string, source: string, expected?: GitIdentity, origin?: 'host' | 'remote' | 'empty'): Promise<GitIdentity> {
const root = await prepareRoot(policy.workspaceRoot, namespace), lockPath = join(root, 'repository.lock')
if (origin && !await fs.lstat(join(root, 'host-sealed')).catch(() => null)) {
if (await fs.lstat(lockPath).catch(() => null)) throw new Error('Sandbox Git is in use')
await sealWorkspace(root)
await fs.writeFile(join(root, 'host-sealed'), '1\n', { flag: 'wx' })
}
const lock = await fs.open(lockPath, 'wx', 0o600)
try {
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
const current = await activeIdentity(root)
if (expected && (current?.id !== expected.id || current.manifestSha256 !== expected.manifestSha256)) throw new Error('Sandbox Git revision changed during control-plane operation')
const next = await storeRepository(root, source, policy, await scanRepository(source), undefined, origin)
await assertPolicyCurrent(policy)
await publishIdentity(root, next)
return next
} finally { await lock.close(); await fs.unlink(lockPath) }
}
export async function sandboxGitIdentity(workspace: string, namespace: string): Promise<GitIdentity | null> {
return activeIdentity(await prepareRoot(workspace, namespace))
}
export async function sandboxGitState(workspaceInput: string): Promise<Record<string, unknown>> {
const workspace = await fs.realpath(workspaceInput), root = await prepareRoot(workspace), identity = await activeIdentity(root)
if (!identity) return { mode: 'private-snapshot', state: 'missing' }
const manifest = await manifestAt(root, identity, workspace)
return { mode: 'private-snapshot', baseline: 'filtered-workspace', state: 'saved', ...identity, policyFingerprint: manifest.policyFingerprint, files: manifest.files.length, bytes: manifest.bytes, createdAt: manifest.createdAt }
}
/** Archive only the active reference; immutable revisions remain available for inspection. */
export async function resetSandboxGit(workspaceInput: string, namespace?: string): Promise<void> {
const workspace = await fs.realpath(workspaceInput), root = await prepareRoot(workspace, namespace), lockPath = join(root, 'repository.lock')
const lock = await fs.open(lockPath, 'wx', 0o600)
try {
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
if (await activeIdentity(root)) await fs.rename(join(root, 'active.json'), join(root, `archived-${randomUUID()}.json`))
} finally { await lock.close(); await fs.unlink(lockPath) }
}
export async function recoverSandboxGitLock(workspace: string, namespace: string): Promise<void> {
const path = join(repositoryRoot(workspace, namespace), 'repository.lock')
if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return
const bytes = await regular(path, 4096), value = JSON.parse(bytes.toString('utf8')) as { pid: number }
if (!Number.isSafeInteger(value.pid) || value.pid < 1) throw new Error('Sandbox Git lock identity is invalid')
try { process.kill(value.pid, 0); throw new Error('Sandbox Git lock still has a live owner') }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
if (!(await regular(path, 4096)).equals(bytes)) throw new Error('Sandbox Git lock changed during recovery')
await fs.unlink(path)
}
+59
View File
@@ -0,0 +1,59 @@
import { promises as fs } from 'node:fs'
import { basename, dirname, join } from 'node:path'
/** Read PE imports without loading or executing an imported program on the host. */
export function gitRuntimeImports(bytes: Buffer): string[] {
const checked = (offset: number, size: number): number => {
if (!Number.isSafeInteger(offset) || offset < 0 || offset + size > bytes.length) throw new Error('Invalid Git x64 PE image')
return offset
}
checked(0, 64)
const pe = bytes.readUInt32LE(0x3c)
checked(pe, 24)
if (bytes.readUInt16LE(0) !== 0x5a4d || bytes.readUInt32LE(pe) !== 0x4550 || bytes.readUInt16LE(pe + 4) !== 0x8664) throw new Error('Git runtime must be a Windows x64 PE image')
const optional = pe + 24, size = bytes.readUInt16LE(pe + 20), sections = bytes.readUInt16LE(pe + 6)
checked(optional, size)
if (size < 224 || bytes.readUInt16LE(optional) !== 0x20b || sections < 1 || sections > 96) throw new Error('Unsupported Git PE layout')
const sectionTable = optional + size
checked(sectionTable, sections * 40)
const offsetFor = (rva: number): number => {
for (let i = 0; i < sections; i++) {
const section = sectionTable + i * 40, address = bytes.readUInt32LE(section + 12), raw = bytes.readUInt32LE(section + 16)
if (rva >= address && rva - address < raw) return checked(bytes.readUInt32LE(section + 20) + rva - address, 1)
}
throw new Error('Git PE import points outside a file section')
}
const imports = new Set<string>()
for (const [directory, stride, nameOffset] of [[1, 20, 12], [13, 32, 4]]) {
const rva = bytes.readUInt32LE(optional + 112 + directory * 8)
if (!rva) continue
let descriptor = offsetFor(rva)
for (let i = 0; ; i++, descriptor += stride) {
checked(descriptor, stride)
const nameRva = bytes.readUInt32LE(descriptor + nameOffset)
if (!nameRva) break
if (i >= 256 || directory === 13 && bytes.readUInt32LE(descriptor) !== 1) throw new Error('Unsupported Git PE import table')
const start = offsetFor(nameRva), end = bytes.indexOf(0, start)
if (end < start || end - start > 128) throw new Error('Invalid Git DLL name')
const name = bytes.subarray(start, end).toString('ascii')
if (!/^[a-z0-9_.-]+\.dll$/i.test(name)) throw new Error('Invalid imported Git DLL name')
imports.add(name.toLowerCase())
}
}
return [...imports]
}
export async function gitRuntimeExecutable(input: string): Promise<string> {
const parent = dirname(input)
// Git for Windows cmd/bin entry points are launchers; copy the actual MinGW image.
if (['cmd', 'bin'].includes(basename(parent).toLowerCase())) {
for (const prefix of ['ucrt64', 'mingw64']) {
const candidate = join(dirname(parent), prefix, 'bin/git.exe')
if (await fs.lstat(candidate).catch(() => null)) return candidate
}
}
return input
}
export const gitSystemDll = (name: string): boolean => /^(?:api-ms-win-[a-z0-9-]+|ext-ms-win-[a-z0-9-]+)\.dll$/.test(name)
|| new Set(['advapi32.dll', 'bcrypt.dll', 'crypt32.dll', 'dbghelp.dll', 'dnsapi.dll', 'gdi32.dll', 'iphlpapi.dll', 'kernel32.dll', 'kernelbase.dll', 'msvcrt.dll', 'netapi32.dll', 'normaliz.dll', 'ntdll.dll', 'ole32.dll', 'oleaut32.dll', 'psapi.dll', 'rpcrt4.dll', 'secur32.dll', 'shell32.dll', 'shlwapi.dll', 'ucrtbase.dll', 'user32.dll', 'userenv.dll', 'version.dll', 'winhttp.dll', 'winmm.dll', 'wldap32.dll', 'ws2_32.dll']).has(name)
+32
View File
@@ -0,0 +1,32 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import type { SandboxCapability, SandboxSelfTestSummary, SandboxToolchainView } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
export function sandboxCapabilities(toolchains: SandboxToolchainView[] = [], persistentWorkspaceAvailable = false, gitWorkflowAvailable = false): SandboxCapability[] {
return [
{ id: 'command-isolation', state: 'implemented', detail: 'Windows x64 AppContainer / Job;installed 不代表本机自检通过' },
{ id: 'network-deny', state: 'implemented', detail: '默认命令不授予网络 capability' },
{ id: 'file-transactions', state: 'implemented', detail: 'Windows x64/NTFS 原生句柄事务,绑定工作区身份;审批、独立备份、增改删和恢复。文件替换为有日志的两步交接,非单次原子交换;完整文件代理及元数据兼容仍待接入' },
{ id: 'persistent-workspace', state: persistentWorkspaceAvailable ? 'implemented' : 'unavailable', detail: '需 0.6.0 原生工作区封存组件;显式 persistent 命令保留同一物理草稿,按项目/会话隔离,提交/丢弃、外部编辑同步和中断恢复;不保留运行进程,普通文件工具仍作用宿主' },
{ id: 'workspace-read-bridge', state: 'implemented', detail: '显式 Node 只读文件桥;NTFS 句柄及工作区身份校验,按 Job 绑定;完整写入代理和逐 PID 认证仍未实现' },
...(['node', 'python'] as const).map(id => ({ id, state: toolchains.some(tool => tool.kind === id && tool.state === 'registered') ? 'implemented' as const : 'unavailable' as const,
detail: '需显式选择已注册运行时;每次执行校验全部文件并复制到只读工具目录;网络默认禁用' })),
{ id: 'git', state: toolchains.some(tool => tool.kind === 'git' && tool.state === 'registered') ? 'implemented' : 'unavailable',
detail: '默认内置 Git,无需注册。保留原沙盒专用仓库;sandbox_git 可审批导入宿主历史/索引,在会话草稿内完成本地工作流,再审批一起提交项目文件和 Git 元数据;init/clone 可发布新仓库。不执行宿主 hook/helper,不自动读取凭证' },
{ id: 'git-host-workflow', state: gitWorkflowAvailable && toolchains.some(tool => tool.kind === 'git' && tool.state === 'registered') ? 'implemented' : 'unavailable', detail: '0.7.0 原生 Git 元数据事务;真实历史/索引导入、普通/linked worktree、多根组提交/补偿/恢复,Git argv 直接进入隔离 Job' },
{ id: 'git-remote-transport', state: gitWorkflowAvailable && toolchains.some(tool => tool.kind === 'git' && tool.state === 'registered' && tool.features?.includes('git-http') && tool.features?.includes('git-ssh')) ? 'implemented' : 'unavailable', detail: '专用受信宿主 Git 代理逐次审批 HTTPS/SSH 目标和引用,DPAPI 用户凭证/固定主机密钥,查询丢失回复和只读核对;普通命令 Job 仍禁网,非通用网络放行' },
...(['network-egress', 'filesystem-broker', 'registry-policy', 'mcp-isolation', 'terminal-isolation'] as const)
.map(id => ({ id, state: 'unavailable' as const, detail: '尚未接入或通过该能力的独立验收' }))
]
}
export async function readSandboxSelfTest(binarySha256: string, policyRevision: string, policyFingerprint: string): Promise<SandboxSelfTestSummary | undefined> {
try {
const path = join(sandboxConfiguration().stateRoot, 'health', 'command-isolation.json')
const stat = await fs.lstat(path)
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 4096) return undefined
const value = JSON.parse(await fs.readFile(path, 'utf8')) as SandboxSelfTestSummary
if (typeof value.passed !== 'boolean' || !Number.isSafeInteger(value.checkedAt) || value.checkedAt > Date.now() || value.binarySha256 !== binarySha256 || value.policyRevision !== policyRevision || value.policyFingerprint !== policyFingerprint || !/^[a-f0-9-]{36}$/.test(value.runId)) return undefined
return value
} catch { return undefined }
}
+115
View File
@@ -0,0 +1,115 @@
import { promises as fs } from 'node:fs'
import { spawn } from 'node:child_process'
import { join, resolve, dirname } from 'node:path'
import { sandboxConfiguration } from './configuration'
import type { SandboxOperationView, SandboxBrokerOperationView, SandboxGitState } from '@shared/sandbox'
const identifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
async function controlRecord(directory: string, filename: string, maximumBytes = 1024 * 1024): Promise<Record<string, unknown> | null> {
const path = join(directory, filename)
const stat = await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
if (!stat) return null
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximumBytes) throw new Error('沙盒操作记录类型或大小无效')
return JSON.parse(await fs.readFile(path, 'utf8')) as Record<string, unknown>
}
/** Native completion alone cannot establish whether an approved host commit happened. */
export async function sandboxOperation(id: string): Promise<SandboxOperationView> {
if (!identifier.test(id)) throw new Error('沙盒操作标识无效')
const directory = join(sandboxConfiguration().stateRoot, 'runs', id)
for (const path of [join(sandboxConfiguration().stateRoot, 'runs'), directory]) {
const stat = await fs.lstat(path)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('沙盒操作目录无效')
}
const request = await controlRecord(directory, 'request.json')
if (!request || request.protocol !== 1 || request.id !== id || typeof request.workspace !== 'string' || !Number.isSafeInteger(request.createdAt)) throw new Error('沙盒操作请求记录无效')
const identity = request.identity as SandboxOperationView['identity']
const base: SandboxOperationView = { id, workspaceRoot: request.workspace, createdAt: Number(request.createdAt), state: 'incomplete', status: 'unknown', writeback: 'unknown', identity: identity ?? {} }
const outcome = await controlRecord(directory, 'outcome.json', 64 * 1024 * 1024) ?? await controlRecord(directory, 'failure.json')
if (!outcome) return base
if (!['committed', 'unchanged', 'rejected', 'not-applied', 'deferred', 'unknown'].includes(String(outcome.writeback)) || !Number.isSafeInteger(outcome.completedAt)) throw new Error('沙盒操作结果记录无效')
if (outcome.transactionId !== undefined && (typeof outcome.transactionId !== 'string' || !identifier.test(outcome.transactionId))) throw new Error('沙盒结果的事务标识无效')
const git = outcome.git as SandboxGitState | undefined
if (git !== undefined && (!git || !['private-snapshot', 'host-mirror', 'remote-clone', 'new-repository'].includes(git.mode)
|| git.baseline !== ({ 'private-snapshot': 'filtered-workspace', 'host-mirror': 'host-repository', 'remote-clone': 'remote-repository', 'new-repository': 'empty-repository' } as const)[git.mode] || typeof git.initialized !== 'boolean'
|| !identifier.test(git.revision) || !/^[a-f0-9]{64}$/.test(git.manifestSha256) || !['unchanged', 'saved', 'rejected', 'not-applied', 'failed'].includes(git.persistence)
|| git.error !== undefined && (typeof git.error !== 'string' || git.error.length > 8192))) throw new Error('沙盒 Git 结果记录无效')
return { ...base, state: outcome.status === 'completed' ? 'completed' : 'failed', status: String(outcome.status), writeback: outcome.writeback as SandboxOperationView['writeback'],
completedAt: Number(outcome.completedAt), ...(outcome.transactionId ? { transactionId: String(outcome.transactionId) } : {}), ...(git ? { git } : {}),
...(outcome.workspace ? { workspace: validateWorkspaceOutcome(outcome.workspace, base.identity.sessionId) } : {}) }
}
function validateWorkspaceOutcome(input: unknown, sessionId?: string): import('@shared/sandbox').SandboxWorkspaceView {
const value = input as import('@shared/sandbox').SandboxWorkspaceView
if (!value || value.mode !== 'persistent' || value.sessionId !== sessionId || !['ready', 'interrupted'].includes(value.state)
|| !value.revision || !identifier.test(value.revision) || !Array.isArray(value.changes) || value.changes.length > 10000
|| value.changes.some(change => typeof change.path !== 'string' || change.path.length > 1024 || !['create', 'update', 'delete', 'mkdir', 'rmdir'].includes(change.operation)
|| ![change.beforeHash, change.afterHash].every(hash => hash === null || typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash)))) throw new Error('长期工作区运行结果归属或格式无效')
return value
}
/** A completed read remains queryable after disconnect; interrupted pending records never imply success. */
export async function sandboxBrokerOperation(runId: string, requestId: string): Promise<SandboxBrokerOperationView> {
if (!identifier.test(requestId)) throw new Error('文件桥请求标识无效')
const run = await sandboxOperation(runId), directory = join(sandboxConfiguration().stateRoot, 'runs', runId, 'broker')
const stat = await fs.lstat(directory)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('文件桥操作目录无效')
const record = await controlRecord(directory, `${requestId}.json`)
const owner = record?.owner as SandboxBrokerOperationView['owner'] | undefined
if (!record || record.version !== 1 || record.requestId !== requestId || typeof record.operation !== 'string' || record.operation.length > 64
|| !owner || owner.sandboxRunId !== runId || !Number.isSafeInteger(record.createdAt)
|| !['pending', 'running', 'succeeded', 'denied', 'failed', 'cancelled'].includes(String(record.state))
|| ['runId', 'toolCallId', 'sessionId', 'policyRevision', 'policyFingerprint'].some(key => owner[key as keyof typeof owner] !== run.identity[key as keyof typeof run.identity])) throw new Error('文件桥操作记录或归属无效')
const result = record as unknown as SandboxBrokerOperationView
if (result.state === 'pending' || result.state === 'running') return { ...result, state: 'unknown' }
if (!Number.isSafeInteger(result.completedAt)) throw new Error('文件桥完成记录无效')
return result
}
async function invoke(binary: string, arguments_: string[]): Promise<boolean> {
return new Promise(done => {
const child = spawn(binary, arguments_, { cwd: dirname(binary), windowsHide: true, stdio: 'ignore' })
const timeout = setTimeout(() => { child.kill(); done(false) }, 10_000)
child.once('error', () => { clearTimeout(timeout); done(false) })
child.once('close', code => { clearTimeout(timeout); done(code === 0) })
})
}
/** Read only the host-owned lease. Never remove a drive by name without its exact target. */
export async function releaseRunLease(binary: string, directory: string): Promise<'active' | 'released' | 'missing' | 'failed'> {
const id = directory.split(/[\\/]/).at(-1) ?? ''
if (!identifier.test(id) || resolve(directory) !== resolve(sandboxConfiguration().stateRoot, 'runs', id)) throw new Error('沙盒租约目录不属于当前状态目录')
const raw = await fs.readFile(join(directory, 'lease.json'), 'utf8').catch(error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
})
if (raw === null) return 'missing'
if (raw.length > 4096) throw new Error('沙盒租约超过协议上限')
const lease = JSON.parse(raw) as Record<string, unknown>
const stage = resolve(directory, 'stage')
if (lease.version !== 1 || lease.profile !== `YCode.Run.${id.replaceAll('-', '')}` || typeof lease.drive !== 'string'
|| !/^[D-Z]:$/.test(lease.drive) || typeof lease.stage !== 'string' || resolve(lease.stage) !== stage || !Number.isInteger(lease.pid) || Number(lease.pid) < 1) throw new Error('沙盒租约无效')
try { process.kill(Number(lease.pid), 0); return 'active' }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return 'active' }
const profile = await invoke(binary, ['--cleanup', String(lease.profile)])
const drive = await invoke(binary, ['--release-drive', lease.drive, stage])
return profile && drive ? 'released' : 'failed'
}
export async function sandboxRecords(collection: 'runs' | 'transactions', limit = 100): Promise<Array<Record<string, unknown>>> {
const root = join(sandboxConfiguration().stateRoot, collection)
const entries = await fs.readdir(root, { withFileTypes: true }).catch(error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
})
const records: Array<Record<string, unknown>> = []
for (const entry of entries.filter(item => item.isDirectory() && !item.isSymbolicLink() && identifier.test(item.name)).slice(-limit)) {
const directory = join(root, entry.name)
const candidates = collection === 'runs' ? ['audit.json', 'failure.json', 'result.json', 'request.json'] : ['manifest.json']
for (const file of candidates) {
const stat = await fs.lstat(join(directory, file)).catch(() => null)
if (!stat?.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) continue
const record = JSON.parse(await fs.readFile(join(directory, file), 'utf8')) as Record<string, unknown>
records.push({ id: entry.name, record: file, status: record.status ?? 'incomplete', createdAt: record.createdAt,
completedAt: record.completedAt, writeback: record.writeback, transactionId: record.transactionId, changes: record.changes,
identity: record.identity, policyRevision: record.policyRevision, git: record.git,
...(collection === 'transactions' ? { workspaceRoot: record.workspaceRoot, entries: record.entries, directories: record.directories } : {}) })
break
}
}
return records
}
+32
View File
@@ -0,0 +1,32 @@
import { promises as fs } from 'node:fs'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import { sandboxConfiguration } from './configuration'
async function checkedBytes(path: string, limit: number): Promise<Buffer> {
const before = await fs.lstat(path)
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > limit) throw new Error('沙盒组件类型或大小无效')
const handle = await fs.open(path, 'r')
try {
const opened = await handle.stat()
if (opened.ino !== before.ino || opened.dev !== before.dev || opened.nlink !== 1 || opened.size !== before.size) throw new Error('沙盒组件在读取前发生变化')
const bytes = await handle.readFile(), after = await handle.stat()
if (bytes.length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) throw new Error('沙盒组件在读取期间发生变化')
return bytes
} finally { await handle.close() }
}
export async function verifiedBinary(requireTransactions = false, requireWorkspace = false, requireGitMetadata = false): Promise<string> {
const directory = sandboxConfiguration().resourceDirectory
const manifest = JSON.parse(await fs.readFile(join(directory, 'manifest.json'), 'utf8')) as Record<string, unknown>
const binary = join(directory, 'ycode-sandbox.exe')
const hash = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
if (manifest.protocol !== 1 || manifest.backend !== 'windows-appcontainer' || manifest.architecture !== 'x64'
|| requireTransactions && manifest.workspaceTransactions !== 1
|| requireWorkspace && manifest.workspaceState !== 1
|| requireGitMetadata && manifest.gitMetadataTransactions !== 1
|| typeof manifest.binarySha256 !== 'string' || hash(await checkedBytes(binary, 16 * 1024 * 1024)) !== manifest.binarySha256) throw new Error('沙盒原生组件缺失、版本不匹配或摘要校验失败;请运行 npm run sandbox:build')
for (const [file, key] of [['node-bridge.cjs', 'nodeBridgeSha256'], ['node-process.cjs', 'nodeProcessSha256'], ['python-sitecustomize.py', 'pythonAdapterSha256']]) {
if (typeof manifest[key] !== 'string' || hash(await checkedBytes(join(directory, file), 64 * 1024)) !== manifest[key]) throw new Error('沙盒适配组件缺失或摘要校验失败;请运行 npm run sandbox:build')
}
return binary
}
+165
View File
@@ -0,0 +1,165 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { StringDecoder } from 'node:string_decoder'
import type { SandboxCallIdentity } from './transactions'
import { NativeWorkspaceReader, SandboxReadError } from './filesystem-reader'
import { assertActive } from './policy'
import { assertPolicyCurrent } from './policy-store'
export const READ_BRIDGE_PREFIX = '\x1eYCODE_WORKSPACE_READ_V1 '
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
const chunkBytes = 16 * 1024
type Json = Record<string, unknown>
interface Operation extends Json {
version: 1; requestId: string; operation: string; owner: SandboxCallIdentity & { sandboxRunId: string }
state: 'pending' | 'running' | 'succeeded' | 'denied' | 'failed' | 'cancelled'
createdAt: number; completedAt?: number; result?: Json; error?: { code: string; message: string }
}
interface ReadSnapshot { bytes: Buffer; identity: string; sha256: string; accessedAt: number }
/** The inherited channel grants one Job a read-only workspace scope, not per-PID authority. */
export class WorkspaceReadBroker {
private readonly decoder = new StringDecoder('utf8')
private buffered = ''
private lineStart = true
private closed = false
private readonly stopSignal = new AbortController()
private readonly operations = new Map<string, Operation>()
private readonly work = new Set<Promise<void>>()
private readonly snapshots = new Map<string, ReadSnapshot>()
private snapshotBytes = 0
failure?: Error
constructor(private readonly reader: NativeWorkspaceReader, private readonly directory: string,
private readonly owner: Operation['owner'], private readonly output: (bytes: Buffer) => void,
private readonly reply: (line: string) => Promise<void>, private readonly stop: () => void) {}
private fail(message: string): void {
if (this.failure || this.closed) return
this.failure = new Error(`沙盒文件桥协议:${message}`)
this.stopSignal.abort(); this.stop()
}
accept(bytes: Buffer): void {
if (this.closed || this.failure) return
this.buffered += this.decoder.write(bytes)
while (this.buffered) {
if (this.lineStart) {
if (READ_BRIDGE_PREFIX.startsWith(this.buffered)) return
if (this.buffered.startsWith(READ_BRIDGE_PREFIX)) {
const end = this.buffered.indexOf('\n')
if (Buffer.byteLength(end < 0 ? this.buffered : this.buffered.slice(0, end)) > 8192) { this.fail('请求帧超过上限'); return }
if (end < 0) return
const raw = this.buffered.slice(READ_BRIDGE_PREFIX.length, end).replace(/\r$/, '')
this.buffered = this.buffered.slice(end + 1)
this.request(raw)
if (this.failure) return
continue
}
}
const end = this.buffered.indexOf('\n')
if (end < 0) { this.output(Buffer.from(this.buffered)); this.buffered = ''; this.lineStart = false; return }
this.output(Buffer.from(this.buffered.slice(0, end + 1)))
this.buffered = this.buffered.slice(end + 1); this.lineStart = true
}
}
private request(raw: string): void {
if (this.work.size >= 8 || this.operations.size >= 1024) { this.fail('并发或总请求数量超过上限'); return }
let request: Json
try {
request = JSON.parse(raw)
if (!request || Array.isArray(request) || request.version !== 1 || typeof request.requestId !== 'string' || !uuid.test(request.requestId)
|| this.operations.has(request.requestId) || typeof request.operation !== 'string' || request.operation.length > 64
|| !request.args || typeof request.args !== 'object' || Array.isArray(request.args)
|| Object.keys(request).some(key => !['version', 'requestId', 'operation', 'args'].includes(key))) throw new Error('invalid request')
} catch { this.fail('格式、归属字段或 request ID 无效/重放'); return }
const operation: Operation = { version: 1, requestId: String(request.requestId), operation: String(request.operation), owner: this.owner, state: 'pending', createdAt: Date.now() }
this.operations.set(operation.requestId, operation)
const pending = this.execute(operation, request.args as Json).catch(error => this.fail(error instanceof Error ? error.message : String(error)))
this.work.add(pending)
void pending.finally(() => this.work.delete(pending))
}
private async save(operation: Operation): Promise<void> {
const temporary = join(this.directory, `${operation.requestId}.tmp`), destination = join(this.directory, `${operation.requestId}.json`)
const handle = await fs.open(temporary, 'wx', 0o600)
try { await handle.writeFile(JSON.stringify(operation) + '\n'); await handle.sync() } finally { await handle.close() }
await fs.rename(temporary, destination)
}
private async execute(operation: Operation, args: Json): Promise<void> {
const signal = AbortSignal.any([this.stopSignal.signal, AbortSignal.timeout(5000)])
let value: Json | undefined
try {
await this.save(operation)
assertActive(signal); await assertPolicyCurrent(this.reader.policy)
operation.state = 'running'; await this.save(operation)
value = await this.dispatch(operation.operation, args, signal)
assertActive(signal); await assertPolicyCurrent(this.reader.policy)
if (Buffer.byteLength(JSON.stringify(value)) > 24 * 1024) throw new SandboxReadError('E2BIG', '文件桥结果帧超过上限')
operation.state = 'succeeded'
// Journals retain metadata only. File contents and read tokens are confined to the live Job.
operation.result = Object.fromEntries(Object.entries(value).filter(([key]) => !['data', 'snapshotId', 'entries'].includes(key)))
} catch (error) {
const code = signal.aborted ? 'ECANCELED' : error instanceof SandboxReadError ? error.code : 'EACCES'
operation.state = signal.aborted ? 'cancelled' : code === 'EACCES' ? 'denied' : 'failed'
operation.error = { code, message: error instanceof SandboxReadError ? error.message : '文件桥操作已取消或策略不再有效' }
}
operation.completedAt = Date.now(); await this.save(operation)
if (this.closed || this.failure) return
await this.reply(JSON.stringify({ version: 1, requestId: operation.requestId, ok: operation.state === 'succeeded', ...(operation.error ? { error: operation.error } : { value }) }) + '\n')
}
private exact(args: Json, keys: string[]): void {
if (Object.keys(args).some(key => !keys.includes(key)) || keys.some(key => !Object.hasOwn(args, key))) throw new SandboxReadError('EACCES', '文件桥参数或归属无效')
}
private part(snapshotId: string, snapshot: ReadSnapshot, offset: number): Json {
snapshot.accessedAt = Date.now()
const data = snapshot.bytes.subarray(offset, offset + chunkBytes)
return { snapshotId, identity: snapshot.identity, sha256: snapshot.sha256, size: snapshot.bytes.length, offset, data: data.toString('base64'), eof: offset + data.length >= snapshot.bytes.length }
}
private async dispatch(operation: string, args: Json, signal: AbortSignal): Promise<Json> {
for (const [id, snapshot] of this.snapshots) if (Date.now() - snapshot.accessedAt > 60_000) { this.snapshotBytes -= snapshot.bytes.length; this.snapshots.delete(id) }
switch (operation) {
case 'workspace.readFile': {
this.exact(args, ['path'])
if (this.snapshots.size >= 8) throw new SandboxReadError('EBUSY', '打开的读取快照超过上限')
const read = await this.reader.read('read', args.path, signal), bytes = Buffer.from(read.data!, 'base64')
if (this.snapshots.size >= 8) throw new SandboxReadError('EBUSY', '打开的读取快照超过上限')
if (this.snapshotBytes + bytes.length > Math.min(this.reader.policy.limits.bytes, 16 * 1024 * 1024)) throw new SandboxReadError('E2BIG', '文件桥快照内存超过上限')
const snapshot = { bytes, identity: read.identity, sha256: createHash('sha256').update(bytes).digest('hex'), accessedAt: Date.now() }, id = randomUUID()
this.snapshots.set(id, snapshot); this.snapshotBytes += bytes.length
return { ...this.part(id, snapshot, 0), path: read.path }
}
case 'workspace.readChunk': {
this.exact(args, ['snapshotId', 'offset'])
const snapshot = typeof args.snapshotId === 'string' ? this.snapshots.get(args.snapshotId) : undefined
if (!snapshot) throw new SandboxReadError('ENOENT', '读取快照不存在、已过期或属于其他运行')
if (!Number.isSafeInteger(args.offset) || Number(args.offset) < 0 || Number(args.offset) > snapshot.bytes.length) throw new SandboxReadError('EACCES', '读取位置无效')
return this.part(String(args.snapshotId), snapshot, Number(args.offset))
}
case 'workspace.closeRead': {
this.exact(args, ['snapshotId'])
const snapshot = typeof args.snapshotId === 'string' ? this.snapshots.get(args.snapshotId) : undefined
if (snapshot) { this.snapshots.delete(String(args.snapshotId)); this.snapshotBytes -= snapshot.bytes.length }
return { closed: !!snapshot }
}
case 'workspace.stat': case 'workspace.readdir': {
this.exact(args, ['path'])
const { data: _data, protocol: _protocol, rootIdentity: _root, ...value } = await this.reader.read(operation === 'workspace.stat' ? 'stat' : 'list', args.path, signal)
return value
}
case 'operation.query': {
this.exact(args, ['requestId'])
const target = typeof args.requestId === 'string' ? this.operations.get(args.requestId) : undefined
if (!target) throw new SandboxReadError('ENOENT', '此运行没有对应的文件桥操作')
return { requestId: target.requestId, operation: target.operation, state: target.state, createdAt: target.createdAt, completedAt: target.completedAt, result: target.result, error: target.error }
}
default: throw new SandboxReadError('EACCES', '文件桥只开放工作区读取、元数据、目录和结果查询')
}
}
async finish(cancelled: boolean): Promise<void> {
if (this.closed) return
if (!cancelled && (this.work.size || this.lineStart && this.buffered && (this.buffered.startsWith(READ_BRIDGE_PREFIX) || READ_BRIDGE_PREFIX.startsWith(this.buffered)))) this.fail('进程在操作完成或帧结束前退出')
this.closed = true; this.stopSignal.abort()
this.buffered += this.decoder.end()
if (this.buffered && !READ_BRIDGE_PREFIX.startsWith(this.buffered) && !this.buffered.startsWith(READ_BRIDGE_PREFIX)) this.output(Buffer.from(this.buffered))
await Promise.allSettled(this.work)
this.snapshots.clear(); this.snapshotBytes = 0
}
}
+147
View File
@@ -0,0 +1,147 @@
import { promises as fs } from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import { isAbsolute, join, relative, resolve } from 'node:path'
import type { SandboxEffectivePolicy, SandboxLimits, SandboxPolicyDocument, SandboxPolicyOverride, SandboxPolicyRules, SandboxPolicyUpdate } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { protectedPath } from './policy'
export const SANDBOX_LIMITS: Readonly<SandboxLimits> = Object.freeze({ files: 10_000, bytes: 128 * 1024 * 1024, fileBytes: 8 * 1024 * 1024, timeoutMs: 120_000 })
const defaultRevision = 'sandbox-policy-v1-default'
const maximumBytes = 256 * 1024
const keys = ['commandsEnabled', 'writeback', 'limits', 'deniedPaths', 'maxChanges']
const limitsKeys = ['files', 'bytes', 'fileBytes', 'timeoutMs'] as const
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('沙盒策略必须是对象')
return value as Record<string, unknown>
}
function knownKeys(value: Record<string, unknown>, allowed: readonly string[]): void {
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('沙盒策略包含未知字段')
}
function deniedPath(value: unknown): string {
if (typeof value !== 'string' || !value || value.length > 1024 || isAbsolute(value) || /[:*?\x00-\x1f]/.test(value)) throw new Error('沙盒拒绝路径必须是工作区相对路径')
const parts = value.replaceAll('\\', '/').split('/')
if (parts.some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) throw new Error('沙盒拒绝路径包含无效组件')
return parts.join('/')
}
export function validatePolicyOverride(input: unknown): SandboxPolicyOverride {
const value = object(input); knownKeys(value, keys)
const result: SandboxPolicyOverride = {}
if (Object.hasOwn(value, 'commandsEnabled')) {
if (typeof value.commandsEnabled !== 'boolean') throw new Error('命令开关无效')
result.commandsEnabled = value.commandsEnabled
}
if (Object.hasOwn(value, 'writeback')) {
if (value.writeback !== 'ask' && value.writeback !== 'deny') throw new Error('当前写回策略只支持 ask/deny')
result.writeback = value.writeback
}
if (Object.hasOwn(value, 'limits')) {
const limits = object(value.limits); knownKeys(limits, limitsKeys)
result.limits = {}
for (const key of limitsKeys) if (Object.hasOwn(limits, key)) {
const limit = limits[key]
const ceiling = key === 'timeoutMs' ? 600_000 : SANDBOX_LIMITS[key]
if (!Number.isInteger(limit) || Number(limit) < (key === 'timeoutMs' ? 100 : 1) || Number(limit) > ceiling) throw new Error(`沙盒资源上限无效:${key}`)
result.limits[key] = Number(limit)
}
}
if (Object.hasOwn(value, 'deniedPaths')) {
if (!Array.isArray(value.deniedPaths) || value.deniedPaths.length > 256) throw new Error('拒绝路径列表无效')
result.deniedPaths = [...new Set(value.deniedPaths.map(deniedPath))]
}
if (Object.hasOwn(value, 'maxChanges')) {
if (!Number.isInteger(value.maxChanges) || Number(value.maxChanges) < 1 || Number(value.maxChanges) > SANDBOX_LIMITS.files) throw new Error('沙盒变更项数上限无效')
result.maxChanges = Number(value.maxChanges)
}
return result
}
function defaultRules(): SandboxPolicyRules {
return { commandsEnabled: true, writeback: 'ask', limits: { ...SANDBOX_LIMITS }, deniedPaths: [], maxChanges: SANDBOX_LIMITS.files }
}
function normalizedRoot(root: string): string { return process.platform === 'win32' ? root.toLowerCase() : root }
function workspaceKey(root: string): string { return createHash('sha256').update(normalizedRoot(root)).digest('hex') }
function defaults(): SandboxPolicyDocument { return { version: 1, revision: defaultRevision, updatedAt: 0, defaults: defaultRules(), workspaces: {} } }
export function sandboxPolicyFingerprint(document: SandboxPolicyDocument): string { return createHash('sha256').update(JSON.stringify(document)).digest('hex') }
function merge(base: SandboxPolicyRules, override: SandboxPolicyOverride): SandboxPolicyRules {
return { ...base, ...override, limits: { ...base.limits, ...override.limits }, deniedPaths: override.deniedPaths ? [...override.deniedPaths] : [...base.deniedPaths] }
}
async function regularControlFile(path: string): Promise<Buffer | null> {
const stat = await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
if (!stat) return null
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximumBytes) throw new Error('沙盒策略文件类型或大小无效')
const handle = await fs.open(path, 'r')
try {
const opened = await handle.stat()
if (opened.ino !== stat.ino || opened.dev !== stat.dev || opened.nlink !== 1) throw new Error('沙盒策略文件发生替换')
const bytes = await handle.readFile()
if (bytes.length > maximumBytes || bytes.length !== opened.size) throw new Error('沙盒策略文件在读取时变化')
return bytes
} finally { await handle.close() }
}
export async function readSandboxPolicy(): Promise<SandboxPolicyDocument> {
const bytes = await regularControlFile(join(sandboxConfiguration().stateRoot, 'policy.json'))
if (!bytes) return defaults()
const value = object(JSON.parse(bytes.toString('utf8')))
knownKeys(value, ['version', 'revision', 'updatedAt', 'defaults', 'workspaces'])
if (value.version !== 1 || typeof value.revision !== 'string' || !/^[a-f0-9-]{36}$/.test(value.revision) || !Number.isSafeInteger(value.updatedAt)) throw new Error('沙盒策略版本或身份无效')
const base = validatePolicyOverride(value.defaults)
if (keys.some(key => !Object.hasOwn(base, key)) || limitsKeys.some(key => !Object.hasOwn(base.limits!, key))) throw new Error('沙盒默认策略不完整')
const workspaces = object(value.workspaces)
if (Object.keys(workspaces).length > 128) throw new Error('工作区策略数量超过上限')
const result: SandboxPolicyDocument = { version: 1, revision: value.revision, updatedAt: Number(value.updatedAt), defaults: merge(defaultRules(), base), workspaces: {} }
for (const [key, raw] of Object.entries(workspaces)) {
const item = object(raw); knownKeys(item, ['root', 'rules'])
if (typeof item.root !== 'string' || !isAbsolute(item.root) || workspaceKey(item.root) !== key) throw new Error('工作区策略归属无效')
result.workspaces[key] = { root: item.root, rules: validatePolicyOverride(item.rules) }
}
return result
}
export async function effectiveSandboxPolicy(rootInput: string): Promise<SandboxEffectivePolicy> {
const root = await fs.realpath(rootInput), doc = await readSandboxPolicy()
const override = doc.workspaces[workspaceKey(root)]?.rules ?? {}
const rules = merge(doc.defaults, override)
rules.commandsEnabled = doc.defaults.commandsEnabled && rules.commandsEnabled
rules.writeback = doc.defaults.writeback === 'deny' ? 'deny' : rules.writeback
rules.maxChanges = Math.min(doc.defaults.maxChanges, rules.maxChanges)
rules.deniedPaths = [...new Set([...doc.defaults.deniedPaths, ...rules.deniedPaths])]
for (const key of limitsKeys) rules.limits[key] = Math.min(doc.defaults.limits[key], rules.limits[key])
return { ...rules, revision: doc.revision, fingerprint: sandboxPolicyFingerprint(doc), workspaceRoot: root }
}
export function sandboxPathDenied(policy: SandboxEffectivePolicy, absolute: string): boolean {
const path = relative(policy.workspaceRoot, absolute).replaceAll('\\', '/')
if (path === '..' || path.startsWith('../') || isAbsolute(path) || protectedPath(path)) return true
const key = normalizedRoot(path)
return policy.deniedPaths.some(item => key === normalizedRoot(item) || key.startsWith(`${normalizedRoot(item)}/`))
}
export async function assertPolicyCurrent(policy: SandboxEffectivePolicy): Promise<void> {
const document = await readSandboxPolicy()
if (document.revision !== policy.revision || sandboxPolicyFingerprint(document) !== policy.fingerprint) throw new Error('沙盒策略已变化,旧执行或审批不再有效,请重试')
}
export async function updateSandboxPolicy(input: SandboxPolicyUpdate): Promise<SandboxPolicyDocument> {
const value = object(input); knownKeys(value, ['expectedRevision', 'rules', 'workspaceRoot'])
if (typeof input.expectedRevision !== 'string') throw new Error('必须指定预期策略版本')
const rules = validatePolicyOverride(input.rules)
const root = input.workspaceRoot === undefined ? undefined : await fs.realpath(input.workspaceRoot)
const state = resolve(sandboxConfiguration().stateRoot)
await fs.mkdir(state, { recursive: true, mode: 0o700 })
if ((await fs.lstat(state)).isSymbolicLink()) throw new Error('沙盒状态目录不能是链接')
const lockPath = join(state, 'policy.lock'), temporary = join(state, `policy-${randomUUID()}.tmp`)
const lock = await fs.open(lockPath, 'wx', 0o600).catch(error => { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('沙盒策略正在被其他维护进程修改;请检查 policy.lock 后重试'); throw error })
try {
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n')
const doc = await readSandboxPolicy()
if (doc.revision !== input.expectedRevision) throw new Error('沙盒策略版本冲突,请刷新后重试')
if (root) {
const key = workspaceKey(root)
doc.workspaces[key] = { root, rules: { ...doc.workspaces[key]?.rules, ...rules, ...(rules.limits ? { limits: { ...doc.workspaces[key]?.rules.limits, ...rules.limits } } : {}) } }
if (Object.keys(doc.workspaces).length > 128) throw new Error('工作区策略数量超过上限')
} else doc.defaults = merge(doc.defaults, rules)
doc.revision = randomUUID(); doc.updatedAt = Date.now()
const bytes = JSON.stringify(doc, null, 2) + '\n'
if (Buffer.byteLength(bytes) > maximumBytes) throw new Error('沙盒策略超出大小上限')
const output = await fs.open(temporary, 'wx', 0o600)
try { await output.writeFile(bytes); await output.sync() } finally { await output.close() }
await fs.rename(temporary, join(state, 'policy.json'))
return doc
} finally { await lock.close(); await fs.unlink(temporary).catch(() => undefined); await fs.unlink(lockPath) }
}
+65
View File
@@ -0,0 +1,65 @@
import { promises as fs } from 'node:fs'
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
export class SandboxPolicyError extends Error {}
export function assertActive(signal?: AbortSignal): void {
if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' })
}
export function within(root: string, target: string): boolean {
const value = relative(root, target)
return value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value)
}
const protectedNames = new Set(['.git', '.ssh', '.aws', '.azure', '.kube', '.gnupg', '.ycode', '.npmrc', '.pypirc', '.netrc', 'credentials', 'credentials.json', 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'])
export function protectedPath(value: string): boolean {
return value.split(/[\\/]/).some(part => {
const name = part.toLowerCase()
return protectedNames.has(name) || name.startsWith('.ycode-sandbox-') || name === '.env' || name.startsWith('.env.') || /\.(?:pem|pfx|p12|key)$/i.test(name)
|| /[:\x00-\x1f]/.test(part) || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
})
}
/** Inspect each existing component. Node realpath alone does not identify hard links. */
export async function resolveSandboxPath(rootInput: string, input: string, fullAccess = false): Promise<string> {
if (typeof input !== 'string' || input.includes('\0')) throw new SandboxPolicyError('文件路径无效')
const root = await fs.realpath(rootInput)
const target = resolve(root, input.trim() || '.')
if (!fullAccess && (!within(root, target) || protectedPath(relative(root, target)))) throw new SandboxPolicyError('默认权限不允许越界或访问受保护文件')
if (fullAccess) {
let existing = target
const suffix: string[] = []
for (;;) {
try { return resolve(await fs.realpath(existing), ...suffix) }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(existing)
if (parent === existing) throw error
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
existing = parent
}
}
}
let existing = target
const suffix: string[] = []
for (;;) {
const stat = await fs.lstat(existing).catch(error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
})
if (stat) {
if (stat.isSymbolicLink() || stat.isFile() && stat.nlink > 1) throw new SandboxPolicyError('默认权限不允许符号链接、junction 或多重硬链接')
const real = await fs.realpath(existing)
if (!within(root, real)) throw new SandboxPolicyError('文件真实路径不在工作区内')
const result = resolve(real, ...suffix)
let ancestor = dirname(existing)
while (within(root, ancestor)) {
if ((await fs.lstat(ancestor)).isSymbolicLink()) throw new SandboxPolicyError('文件父目录包含链接')
if (ancestor === root) break
ancestor = dirname(ancestor)
}
return result
}
const parent = dirname(existing)
if (parent === existing || !within(root, parent)) throw new SandboxPolicyError('无法解析工作区路径')
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
existing = parent
}
}
+30
View File
@@ -0,0 +1,30 @@
import { promises as fs } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import type { SandboxSelfTestSummary } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { executeSandboxCommand, verifiedBinary } from './service'
import { digest } from './transactions'
export async function runSandboxSelfTest(): Promise<SandboxSelfTestSummary> {
const binary = await verifiedBinary(), binarySha256 = digest(await fs.readFile(binary))
const fixture = await fs.mkdtemp(join(tmpdir(), 'YCode-sandbox-selftest-'))
try {
const result = await executeSandboxCommand('echo YCODE_SANDBOX_SELF_TEST', { workspaceRoot: fixture })
if (!result.data.policyRevision || !result.data.policyFingerprint) throw new Error('自检缺少实际执行的策略身份')
const summary: SandboxSelfTestSummary = { passed: result.ok && result.data.stdout.includes('YCODE_SANDBOX_SELF_TEST') && result.data.profileRemoved && result.data.driveRemoved,
checkedAt: Date.now(), binarySha256, policyRevision: result.data.policyRevision, policyFingerprint: result.data.policyFingerprint, runId: result.data.runId }
const directory = join(sandboxConfiguration().stateRoot, 'health')
await fs.mkdir(directory, { recursive: true, mode: 0o700 })
if ((await fs.lstat(directory)).isSymbolicLink()) throw new Error('自检存储目录不能是链接')
const temporary = join(directory, `${randomUUID()}.tmp`)
try {
await fs.writeFile(temporary, JSON.stringify(summary) + '\n', { flag: 'wx', mode: 0o600 })
await fs.rename(temporary, join(directory, 'command-isolation.json'))
} finally { await fs.unlink(temporary).catch(() => undefined) }
return summary
} finally {
if (dirname(resolve(fixture)) === resolve(tmpdir()) && !((await fs.lstat(fixture)).isSymbolicLink())) await fs.rm(fixture, { recursive: true, force: true })
}
}
+295
View File
@@ -0,0 +1,295 @@
import { promises as fs } from 'node:fs'
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import type { SandboxCallIdentity } from './transactions'
import { commitChanges, digest, fileChangeDigest, type FileChange, type DirectoryChange } from './transactions'
import { sandboxConfiguration } from './configuration'
import { assertActive, resolveSandboxPath, within } from './policy'
import { releaseRunLease } from './maintenance'
import type { SandboxChangedFile, SandboxCommandData, SandboxStatus, SandboxEffectivePolicy } from '@shared/sandbox'
import { SANDBOX_LIMITS, readSandboxPolicy, effectiveSandboxPolicy, sandboxPathDenied, assertPolicyCurrent, sandboxPolicyFingerprint } from './policy-store'
import { authorizeSandboxWrite } from './authorizations'
import { sandboxCapabilities, readSandboxSelfTest } from './health'
import { listSandboxToolchains, stageSandboxToolchains } from './toolchains'
import { withSandboxExecution } from './execution'
import { stageSandboxDependencies } from './dependency-cache'
import { StdioBrokerProbe } from './stdio-broker'
import { NativeWorkspaceReader } from './filesystem-reader'
import { WorkspaceReadBroker } from './operation-broker'
import { stageSandboxGit, type SandboxGitSession } from './git-repository'
import { verifiedBinary } from './native-component'
export { verifiedBinary } from './native-component'
import { regularBytes, snapshot, stageChanges } from './workspace-files'
import { PersistentWorkspace } from './workspace'
import { refreshHostGit, loadHostGitBinding, hostGitRoot } from './git-host-source'
export { SANDBOX_LIMITS } from './policy-store'
const active = new Set<() => void>()
export interface SandboxCommandContext extends SandboxCallIdentity {
workspaceRoot: string; signal?: AbortSignal; approve?: (description: string, signal?: AbortSignal) => Promise<boolean>; timeoutMs?: number
runtimes?: string[]
dependencies?: string[]
stdioProbe?: boolean
readBridge?: boolean
workspaceMode?: 'snapshot' | 'persistent'
preserveGitConflicts?: boolean
gitArguments?: string[]
}
interface NativeResult { protocol: number; backend: string; isolated: boolean; network: string; status: SandboxCommandData['status']; exitCode: number; durationMs: number; profileRemoved: boolean; driveRemoved: boolean; broker?: 'bound-stdio-v1' | null }
export async function sandboxStatus(): Promise<SandboxStatus> {
const base: SandboxStatus = { backend: 'windows-appcontainer', protocol: 1, state: 'unavailable', network: 'deny', filesystem: 'workspace-snapshot', writeback: 'approval', limits: { ...SANDBOX_LIMITS }, capabilities: sandboxCapabilities() }
if (process.platform !== 'win32' || process.arch !== 'x64') return { ...base, reason: '当前只提供 Windows x64 原生沙盒' }
try {
const binary = await verifiedBinary(), policy = await readSandboxPolicy()
const selfTest = await readSandboxSelfTest(digest(await fs.readFile(binary)), policy.revision, sandboxPolicyFingerprint(policy))
const toolchains = await listSandboxToolchains()
const component = JSON.parse(await fs.readFile(join(sandboxConfiguration().resourceDirectory, 'manifest.json'), 'utf8'))
base.capabilities = sandboxCapabilities(toolchains, component.workspaceState === 1, component.gitMetadataTransactions === 1)
return { ...base, state: 'installed', limits: policy.defaults.limits, toolchains, policy: { revision: policy.revision, commandsEnabled: policy.defaults.commandsEnabled, writeback: policy.defaults.writeback }, ...(selfTest ? { selfTest } : {}) }
}
catch (error) { return { ...base, reason: error instanceof Error ? error.message : String(error) } }
}
async function runNative(binary: string, profile: string, stage: string, report: string, timeout: number, policy: SandboxEffectivePolicy, signal?: AbortSignal, stdioProbe = false, readBridge?: { owner: SandboxCallIdentity & { sandboxRunId: string } }): Promise<{ stdout: string; stderr: string; truncated: boolean; result: NativeResult }> {
assertActive(signal)
const reader = readBridge ? await NativeWorkspaceReader.create(binary, policy, signal) : undefined
const brokerDirectory = join(dirname(report), 'broker')
if (reader) await fs.mkdir(brokerDirectory, { recursive: true, mode: 0o700 })
// Keep the native monitor alive long enough to clean its Job/profile if Node exits abruptly.
const child = spawn(binary, [stdioProbe || reader ? '--run-stdio' : '--run', profile, stage, report, String(timeout)], { cwd: dirname(binary), windowsHide: true, detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
let killTimer: ReturnType<typeof setTimeout> | undefined
const cancel = (): void => {
if (!child.stdin.destroyed) child.stdin.write('cancel\n', () => undefined)
killTimer ??= setTimeout(() => child.kill(), 2000)
}
active.add(cancel)
child.stdin.on('error', () => undefined)
signal?.addEventListener('abort', cancel, { once: true })
if (signal?.aborted) cancel()
let stdout: Buffer = Buffer.alloc(0), stderr: Buffer = Buffer.alloc(0), truncated = false
const retain = (current: Buffer, bytes: Buffer): Buffer => {
if (current.length + bytes.length > 2 * 1024 * 1024) truncated = true
return Buffer.concat([current, bytes]).subarray(-2 * 1024 * 1024)
}
const sendReply = (line: string): Promise<void> => new Promise((done, reject) => {
if (child.stdin.destroyed) { reject(new Error('文件桥回复通路已关闭')); return }
child.stdin.write(`input:${Buffer.from(line).toString('base64')}\n`, error => error ? reject(error) : done())
})
const broker = reader ? new WorkspaceReadBroker(reader, brokerDirectory, readBridge!.owner, bytes => { stdout = retain(stdout, bytes) }, sendReply, cancel)
: stdioProbe ? new StdioBrokerProbe(policy, bytes => { stdout = retain(stdout, bytes) }, line => {
if (child.stdin.destroyed) throw new Error('代理回复通路已关闭')
child.stdin.write(`input:${Buffer.from(line).toString('base64')}\n`, () => undefined)
}, cancel) : undefined
child.stdout.on('data', (bytes: Buffer) => { if (broker) broker.accept(bytes); else stdout = retain(stdout, bytes) })
child.stderr.on('data', (bytes: Buffer) => { stderr = retain(stderr, bytes) })
const watchdog = setTimeout(cancel, timeout + 15_000)
let checkingPolicy = false
const policyWatch = setInterval(() => {
if (checkingPolicy) return
checkingPolicy = true
void assertPolicyCurrent(policy).catch(cancel).finally(() => { checkingPolicy = false })
}, 250)
try {
const exit = await new Promise<number | null>((done, reject) => { child.once('error', reject); child.once('close', done) })
if (exit !== 0) throw new Error(`沙盒启动或隔离验证失败(${exit}):${stderr.toString('utf8').slice(-1000)}`)
const raw = await fs.readFile(report, 'utf8')
if (raw.length > 4096) throw new Error('沙盒报告超出协议上限')
const result = JSON.parse(raw) as NativeResult
if (result.protocol !== 1 || result.backend !== 'windows-appcontainer' || result.isolated !== true || result.network !== 'deny'
|| !['completed', 'timeout', 'cancelled', 'parent-exited', 'error'].includes(result.status) || !Number.isInteger(result.exitCode)) throw new Error('沙盒隔离报告无效')
if ((stdioProbe || reader) && result.broker !== 'bound-stdio-v1') throw new Error('原生组件不支持已绑定的 stdio 通路')
await broker?.finish(signal?.aborted === true || result.status !== 'completed')
if (broker?.failure) throw broker.failure
return { stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8'), truncated, result }
} catch (error) {
await releaseRunLease(binary, dirname(report)).catch(() => undefined)
throw error
} finally {
await broker?.finish(true)
clearTimeout(watchdog)
clearInterval(policyWatch)
if (killTimer) clearTimeout(killTimer)
active.delete(cancel)
signal?.removeEventListener('abort', cancel)
child.stdin.destroy()
}
}
export function disposeSandboxCommands(): void { for (const cancel of active) cancel() }
async function cleanupStage(directory: string): Promise<void> {
const stage = join(directory, 'stage')
const stateReal = await fs.realpath(sandboxConfiguration().stateRoot)
const directoryReal = await fs.realpath(directory)
const stageStat = await fs.lstat(stage).catch(() => null)
if (!stageStat) return
const stageReal = await fs.realpath(stage)
if (stageStat.isSymbolicLink() || !within(stateReal, directoryReal) || dirname(stageReal) !== directoryReal || stageReal === directoryReal) throw new Error('临时工作区清理路径不合法')
// Only this generated stage is recursive; independent backups and audit records remain.
await fs.rm(stageReal, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
}
export async function executeSandboxCommand(command: string, context: SandboxCommandContext): Promise<{ ok: boolean; content: string; data: SandboxCommandData }> {
if (context.workspaceMode === undefined && context.sessionId && context.runtimes?.includes('git')) {
const policy = await effectiveSandboxPolicy(context.workspaceRoot)
if (await loadHostGitBinding(policy, context.sessionId) || await fs.lstat(join(hostGitRoot(policy.workspaceRoot, context.sessionId), 'clone.json')).catch(() => null)) context = { ...context, workspaceMode: 'persistent' }
}
return withSandboxExecution(context.workspaceRoot, context.signal, canonicalRoot => executeIsolatedCommand(command, { ...context, workspaceRoot: canonicalRoot }))
}
async function executeIsolatedCommand(command: string, context: SandboxCommandContext): Promise<{ ok: boolean; content: string; data: SandboxCommandData }> {
assertActive(context.signal)
if (!command.trim() || command.length > 32_000 || command.includes('\0')) throw new Error('沙盒命令为空、过长或包含无效字符')
if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('当前平台没有可用的系统沙盒,默认权限命令执行被拒绝')
if (context.readBridge !== undefined && typeof context.readBridge !== 'boolean' || context.readBridge && context.stdioProbe) throw new Error('沙盒文件桥配置无效')
if (context.readBridge && !context.runtimes?.includes('node')) throw new Error('当前文件桥需要显式选择 Node 运行时')
if (context.workspaceMode !== undefined && !['snapshot', 'persistent'].includes(context.workspaceMode)) throw new Error('沙盒工作区模式无效')
if (context.workspaceMode === 'persistent' && !context.sessionId) throw new Error('长期工作区必须绑定宿主会话')
const policy = await effectiveSandboxPolicy(context.workspaceRoot)
if (!policy.commandsEnabled) throw new Error('沙盒策略已禁用默认权限命令')
const binary = await verifiedBinary()
const workspace = policy.workspaceRoot
if (within(resolve(sandboxConfiguration().stateRoot), workspace)) throw new Error('沙盒状态目录不能作为命令工作区')
const id = randomUUID(), profile = `YCode.Run.${id.replaceAll('-', '')}`
const directory = join(sandboxConfiguration().stateRoot, 'runs', id), stage = join(directory, 'stage')
for (const part of ['workspace', 'tools', 'tmp', 'home/AppData/Roaming', 'home/AppData/Local']) await fs.mkdir(join(stage, part), { recursive: true })
const identity = { runId: context.runId, toolCallId: context.toolCallId, sessionId: context.sessionId, policyRevision: policy.revision, policyFingerprint: policy.fingerprint }
await fs.writeFile(join(directory, 'request.json'), JSON.stringify({ protocol: 1, id, profile, workspace, identity, createdAt: Date.now() }) + '\n', { flag: 'wx', mode: 0o600 })
let git: SandboxGitSession | undefined
let draft: PersistentWorkspace | undefined
try {
const workspaceIdentity = (await NativeWorkspaceReader.create(binary, policy, context.signal)).rootIdentity
if (context.workspaceMode === 'persistent') {
draft = await PersistentWorkspace.open(policy, context.sessionId!, workspaceIdentity)
await draft.synchronize()
}
const initial = draft ? await draft.checkoutInto(stage, id) : await snapshot(workspace, join(stage, 'workspace'), policy, context.signal)
const toolchains = await stageSandboxToolchains(stage, context.runtimes ?? [], context.signal)
const dependencies = await stageSandboxDependencies(workspace, stage, context.dependencies ?? [], toolchains, context.signal)
if (toolchains.some(tool => tool.kind === 'git')) {
await fs.copyFile(binary, join(stage, 'tools/ycode-git-editor.exe'))
const binding = draft ? await refreshHostGit(policy, context.sessionId!, context.signal) : null
git = await stageSandboxGit(stage, policy, initial.files, context.signal, draft?.namespace)
if (git.state.mode === 'host-mirror' && !binding) throw new Error('Host Git import publication was interrupted; explicitly import again before use')
}
if (toolchains.some(tool => tool.kind === 'node')) {
const manifest = JSON.parse(await fs.readFile(join(dirname(binary), 'manifest.json'), 'utf8')) as { nodeProcessSha256?: string }
const adapter = await regularBytes(join(dirname(binary), 'node-process.cjs'), 64 * 1024)
if (typeof manifest.nodeProcessSha256 !== 'string' || digest(adapter) !== manifest.nodeProcessSha256) throw new Error('Node 子进程适配器缺失或摘要无效;请重新构建沙盒')
await fs.writeFile(join(stage, 'tools/ycode-process.cjs'), adapter, { flag: 'wx' })
}
if (toolchains.some(tool => tool.kind === 'python')) {
const manifest = JSON.parse(await fs.readFile(join(dirname(binary), 'manifest.json'), 'utf8')) as { pythonAdapterSha256?: string }
const adapter = await regularBytes(join(dirname(binary), 'python-sitecustomize.py'), 64 * 1024)
if (typeof manifest.pythonAdapterSha256 !== 'string' || digest(adapter) !== manifest.pythonAdapterSha256) throw new Error('Python 临时目录适配器缺失或摘要无效;请重新构建沙盒')
await fs.mkdir(join(stage, 'tools/python-adapter'))
await fs.writeFile(join(stage, 'tools/python-adapter/sitecustomize.py'), adapter, { flag: 'wx' })
}
if (context.readBridge) {
const manifest = JSON.parse(await fs.readFile(join(dirname(binary), 'manifest.json'), 'utf8')) as { nodeBridgeSha256?: string }
const adapter = await regularBytes(join(dirname(binary), 'node-bridge.cjs'), 64 * 1024)
if (typeof manifest.nodeBridgeSha256 !== 'string' || digest(adapter) !== manifest.nodeBridgeSha256) throw new Error('文件桥客户端缺失或摘要无效;请重新构建沙盒')
await fs.writeFile(join(stage, 'tools/ycode-bridge.cjs'), adapter, { flag: 'wx' })
}
await fs.writeFile(join(directory, 'execution.json'), JSON.stringify({ toolchains, dependencies, policyRevision: policy.revision, readBridge: context.readBridge === true,
workspaceMode: draft ? 'persistent' : 'snapshot', ...(draft ? { workspaceRevision: draft.revision!.id } : {}), ...(git ? { git: git.state } : {}) }) + '\n', { flag: 'wx', mode: 0o600 })
await fs.writeFile(join(stage, 'command.cmd'), `@echo off\r\nchcp 65001 >nul\r\n${command}\r\n`, { flag: 'wx' })
if (context.gitArguments) {
if (!context.runtimes?.includes('git') || context.gitArguments.length > 128 || context.gitArguments.some(arg => typeof arg !== 'string' || arg.includes('\0'))) throw new Error('Invalid isolated Git arguments')
await fs.writeFile(join(stage, 'git-arguments'), context.gitArguments.map(arg => Buffer.from(arg).toString('hex')).join('\n') + '\n', { flag: 'wx' })
}
const timeout = Math.min(context.timeoutMs ?? policy.limits.timeoutMs, policy.limits.timeoutMs)
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 600_000) throw new Error('沙盒超时参数无效')
await assertPolicyCurrent(policy)
const native = await runNative(binary, profile, stage, join(directory, 'result.json'), timeout, policy, context.signal, context.stdioProbe, context.readBridge ? { owner: { ...identity, sandboxRunId: id } } : undefined)
await assertPolicyCurrent(policy)
const data: SandboxCommandData = { stdout: native.stdout, stderr: native.stderr, exitCode: native.result.exitCode,
sandbox: 'windows-appcontainer', isolated: true, runId: id, status: native.result.status, changes: [], writeback: 'not-applied',
excluded: initial.excluded, outputTruncated: native.truncated, profileRemoved: native.result.profileRemoved, driveRemoved: native.result.driveRemoved, auditSaved: false, policyRevision: policy.revision, policyFingerprint: policy.fingerprint, toolchains, dependencies, ...(native.result.broker ? { brokerTransport: native.result.broker } : {}), ...(context.readBridge ? { workspaceReadBridge: true as const } : {}), ...(git ? { git: git.state } : {}) }
let ok = native.result.status === 'completed' && native.result.exitCode === 0 && !context.signal?.aborted
const gitConflict = !ok && !!draft && !!git && context.preserveGitConflicts === true && native.result.status === 'completed' && native.result.exitCode === 1
&& (await Promise.all(['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply'].map(name => fs.lstat(join(stage, 'repository', name)).catch(() => null)))).some(stat => stat && !stat.isSymbolicLink())
if (gitConflict) data.gitConflictState = true
if (ok || gitConflict) {
const delta = await stageChanges(join(stage, 'workspace'), initial.files, initial.directories, policy, dependencies.some(item => item.kind === 'node'), context.signal)
const changes = delta.files, directories = delta.directories
if (changes.length + directories.length > policy.maxChanges) throw new Error('沙盒文件变更超过策略允许的批次上限')
data.changes = changes.map((change): SandboxChangedFile => ({ path: change.path, operation: change.after === null ? 'delete' : change.beforeHash === null ? 'create' : 'update', beforeHash: change.beforeHash, afterHash: change.after === null ? null : digest(change.after) }))
data.changes.push(...directories.map((change): SandboxChangedFile => ({ path: change.path, operation: change.after ? 'mkdir' : 'rmdir', beforeHash: null, afterHash: null })))
const gitCandidate = await git?.capture(context.signal)
if (gitCandidate) await fs.writeFile(join(directory, 'git-candidate.json'), JSON.stringify({ before: git!.state, after: gitCandidate, policyRevision: policy.revision, policyFingerprint: policy.fingerprint }) + '\n', { flag: 'wx', mode: 0o600 })
if (git && !gitCandidate) git.state.persistence = 'unchanged'
if (draft) {
if (!native.result.profileRemoved || !native.result.driveRemoved) throw new Error('长期工作区的原生执行尚未完整清理,拒绝保存草稿')
await draft.finish(stage, dependencies.some(item => item.kind === 'node'), context.signal)
data.workspace = draft.view()
data.writeback = data.workspace.changes.length ? 'deferred' : 'unchanged'
if (gitCandidate) {
try { await git!.publish(gitCandidate) }
catch (error) { git!.state.persistence = 'failed'; git!.state.error = error instanceof Error ? error.message : String(error); ok = false }
}
}
else if (!data.changes.length && !gitCandidate) data.writeback = 'unchanged'
else {
const description = `沙盒命令产生 ${data.changes.length} 项文件/目录变更${gitCandidate ? '及沙盒专用 Git 仓库变更(不写宿主 .git)' : ''},是否保存?\n${data.changes.slice(0, 30).map(change => `${change.operation}: ${change.path}`).join('\n')}`
const changeDigest = (): string => gitCandidate ? digest(Buffer.from(JSON.stringify({ files: fileChangeDigest(changes, directories), git: gitCandidate }))) : fileChangeDigest(changes, directories)
const targetDigest = changeDigest()
const authorization = await authorizeSandboxWrite(policy, description, context, targetDigest)
assertActive(context.signal)
if (authorization) {
data.authorizationId = authorization.id
const beforeApply = async (): Promise<void> => {
if ((await NativeWorkspaceReader.create(binary, policy, context.signal)).rootIdentity !== workspaceIdentity) throw new Error('工作区在执行或审批期间被替换;原授权失效')
if (gitCandidate) {
await git!.assertCurrent()
const current = await snapshot(workspace, undefined, policy, context.signal)
if (current.files.size !== initial.files.size || current.directories.size !== initial.directories.size
|| [...initial.files].some(([path, file]) => current.files.get(path)?.hash !== file.hash)
|| [...initial.directories].some(path => !current.directories.has(path))) throw new Error('工作区在 Git 执行或审批期间变化;未保存沙盒仓库')
}
await authorization.consume(changeDigest())
}
if (data.changes.length) {
data.transactionId = await commitChanges(workspace, changes, { ...identity, authorizationId: authorization.id }, context.signal,
{ beforeApply, check: () => authorization.assertValid(targetDigest), workspaceIdentity }, directories)
data.writeback = 'committed'
} else { await beforeApply(); data.writeback = 'unchanged' }
if (gitCandidate) {
// File writeback has its own recovery record. A failed private publication
// must not hide an already committed host transaction.
try { await authorization.assertValid(targetDigest); await git!.publish(gitCandidate) }
catch (error) { git!.state.persistence = 'failed'; git!.state.error = error instanceof Error ? error.message : String(error); ok = false }
}
}
else { data.writeback = data.changes.length ? 'rejected' : 'unchanged'; if (gitCandidate) git!.state.persistence = 'rejected'; ok = false }
}
}
if (draft?.checkedOut) { await draft.abort(); data.workspace = draft.view() }
try {
const temporary = join(directory, 'outcome.tmp')
await fs.writeFile(temporary, JSON.stringify({ id, status: data.status, writeback: data.writeback, transactionId: data.transactionId, git: data.git, workspace: data.workspace, completedAt: Date.now() }) + '\n', { flag: 'wx', mode: 0o600 })
await fs.rename(temporary, join(directory, 'outcome.json'))
data.outcomeSaved = true
} catch { data.outcomeSaved = false }
try {
data.auditSaved = true
const temporary = join(directory, 'audit.tmp')
await fs.writeFile(temporary, JSON.stringify({ ...data, stdout: undefined, stderr: undefined, identity, completedAt: Date.now() }, null, 2) + '\n', { flag: 'wx', mode: 0o600 })
await fs.rename(temporary, join(directory, 'audit.json'))
} catch { data.auditSaved = false }
const summary = `沙盒:AppContainer;网络已禁用;写回:${data.writeback};排除项:${data.excluded}。`
const content = [data.stdout, data.stderr, summary, draft ? `长期工作区:草稿已保留;未写入宿主;待提交 ${data.workspace?.changes.length ?? 0} 项。用 sandbox_workspace 查看、提交或丢弃;普通文件工具仍访问宿主项目。` : '',
git ? `Git:${git.state.mode === 'host-mirror' ? '已审批导入的宿主仓库会话镜像,包含真实历史/索引,尚未自动写宿主 .git' : git.state.mode === 'remote-clone' ? '远程克隆的会话草稿,尚未发布到宿主仓库' : git.state.mode === 'new-repository' ? '会话中初始化的新仓库,尚未发布到宿主 .git' : `${draft ? '当前会话草稿专用' : '沙盒专用'}仓库;基线为过滤后的工作区快照,不含宿主历史/索引`};仓库保存:${git.state.persistence}。${git.state.error ?? ''}` : '',
gitConflict ? 'Git 操作遇到冲突;冲突文件和合并/变基状态已保存在草稿,可继续解决或 abort。' : '', !data.auditSaved ? '运行审计未成功保存;请根据写回状态和文件事务记录核对结果。' : ''].filter(Boolean).join('\n')
return { ok, content, data }
} catch (error) {
const transaction = error as { transactionId?: string; recoveryRequired?: boolean }
await fs.writeFile(join(directory, 'failure.json'), JSON.stringify({ id, status: context.signal?.aborted ? 'cancelled' : 'error', writeback: transaction.recoveryRequired ? 'unknown' : 'not-applied', transactionId: transaction.transactionId, git: git?.state, completedAt: Date.now() }) + '\n', { mode: 0o600 }).catch(() => undefined)
throw error
} finally {
const gitLockReleased = await git?.close().then(() => true, () => false)
if (draft?.checkedOut) await draft.abort().catch(() => { draft!.interrupted = true })
const stageRemoved = draft?.checkedOut ? false : await cleanupStage(directory).then(() => true, () => false)
const workspaceLockReleased = await draft?.close().then(() => true, () => false)
await fs.writeFile(join(directory, 'cleanup.json'), JSON.stringify({ stageRemoved, ...(git ? { gitLockReleased } : {}), ...(draft ? { workspaceLockReleased, workspaceInterrupted: draft.interrupted } : {}), completedAt: Date.now() }) + '\n', { mode: 0o600 }).catch(() => undefined)
}
}
+63
View File
@@ -0,0 +1,63 @@
import { StringDecoder } from 'node:string_decoder'
import { assertPolicyCurrent } from './policy-store'
import type { SandboxEffectivePolicy } from '@shared/sandbox'
export const STDIO_BROKER_PREFIX = '\x1eYCODE_BROKER_V1 '
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
/** Experimental echo only. Scope comes from this spawned Job, never from fields supplied by its code. */
export class StdioBrokerProbe {
private readonly decoder = new StringDecoder('utf8')
private pending = ''
private readonly seen = new Set<string>()
private inflight = 0
private closed = false
failure?: Error
constructor(private readonly policy: SandboxEffectivePolicy, private readonly output: (bytes: Buffer) => void,
private readonly reply: (line: string) => void, private readonly stop: () => void) {}
private fail(message: string): void {
if (this.closed || this.failure) return
this.failure = new Error(`沙盒代理协议:${message}`); this.stop()
}
accept(bytes: Buffer): void {
if (this.closed || this.failure) return
this.pending += this.decoder.write(bytes)
if (Buffer.byteLength(this.pending) > 65536) { this.fail('输出帧超过实验通路上限'); return }
let newline = this.pending.indexOf('\n')
while (newline >= 0) {
const line = this.pending.slice(0, newline).replace(/\r$/, '')
this.pending = this.pending.slice(newline + 1)
if (line.startsWith(STDIO_BROKER_PREFIX)) this.request(line.slice(STDIO_BROKER_PREFIX.length))
else this.output(Buffer.from(line + '\n'))
if (this.failure) return
newline = this.pending.indexOf('\n')
}
}
private request(raw: string): void {
if (Buffer.byteLength(raw) > 16 * 1024 || this.inflight >= 8 || this.seen.size >= 128) { this.fail('请求大小、并发或总量超过上限'); return }
let request: Record<string, unknown>
try {
request = JSON.parse(raw)
if (!request || typeof request !== 'object' || Array.isArray(request) || typeof request.requestId !== 'string' || !uuid.test(request.requestId) || this.seen.has(request.requestId)) throw new Error('invalid request')
} catch { this.fail('请求格式或 request ID 无效'); return }
const id = String(request.requestId)
this.seen.add(id); this.inflight++
let timeout: ReturnType<typeof setTimeout> | undefined
const expired = new Promise<never>((_done, reject) => { timeout = setTimeout(() => reject(new Error('代理请求超时')), 5000) })
const operation = async (): Promise<Record<string, unknown>> => {
await assertPolicyCurrent(this.policy)
if (Object.keys(request).some(key => !['requestId', 'operation', 'payload'].includes(key))) return { requestId: id, ok: false, error: '调用归属只能由宿主绑定' }
if (request.operation !== 'echo' || typeof request.payload !== 'string' || Buffer.byteLength(request.payload) > 8192) return { requestId: id, ok: false, error: '实验通路只支持有界 echo;文件和网络代理尚未开放' }
return { requestId: id, ok: true, payload: request.payload }
}
void Promise.race([operation(), expired]).then(value => {
if (!this.closed && !this.failure) this.reply(JSON.stringify(value) + '\n')
}).catch(error => this.fail(error instanceof Error ? error.message : String(error))).finally(() => { if (timeout) clearTimeout(timeout); this.inflight-- })
}
finish(cancelled: boolean): void {
if (this.closed) return
this.pending += this.decoder.end()
if (!cancelled && (this.inflight || this.pending.startsWith(STDIO_BROKER_PREFIX))) this.fail('进程在代理请求完成前退出')
if (this.pending && !this.pending.startsWith(STDIO_BROKER_PREFIX)) this.output(Buffer.from(this.pending))
this.closed = true
}
}
+236
View File
@@ -0,0 +1,236 @@
import { promises as fs } from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { SandboxToolchainIdentity, SandboxToolchainKind, SandboxToolchainView } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { assertActive, protectedPath, within } from './policy'
import { gitRuntimeExecutable, gitRuntimeImports, gitSystemDll } from './git-runtime'
interface ToolchainFile { path: string; bytes: number; sha256: string }
interface ToolchainManifest { version: 1; kind: SandboxToolchainKind; id: string; createdAt: number; files: ToolchainFile[]; bytes: number; features: string[]; distribution?: { version: string; archiveSha256: string; releaseUrl: string } }
interface LocatedToolchain { manifest: ToolchainManifest; directory: string; identity: SandboxToolchainIdentity; source: 'bundled' | 'imported' }
const kinds: SandboxToolchainKind[] = ['node', 'python', 'git']
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
const hashPattern = /^[a-f0-9]{64}$/
const ceilings = { files: 12_000, bytes: 512 * 1024 * 1024, fileBytes: 128 * 1024 * 1024, manifestBytes: 4 * 1024 * 1024 }
const hash = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex')
const root = (): string => join(sandboxConfiguration().stateRoot, 'toolchains')
function kindValue(value: string): SandboxToolchainKind {
if (!kinds.includes(value as SandboxToolchainKind)) throw new Error('当前受控运行时只支持 node/python/git')
return value as SandboxToolchainKind
}
function filePath(value: unknown): string {
if (typeof value !== 'string' || !value || value.length > 2048 || isAbsolute(value) || /[:\x00-\x1f]/.test(value)) throw new Error('运行时文件路径无效')
const path = value.replaceAll('\\', '/')
const publicCertificate = path === 'Lib/site-packages/pip/_vendor/certifi/cacert.pem'
if (path.split('/').some(part => !part || part === '.' || part === '..') || protectedPath(path) && !publicCertificate) throw new Error(`运行时文件包含受保护路径或链接目标:${path}`)
return path
}
async function regularBytes(path: string, ceiling: number, allowSourceHardlink = false): Promise<Buffer> {
const stat = await fs.lstat(path)
if (!stat.isFile() || stat.isSymbolicLink() || !allowSourceHardlink && stat.nlink !== 1 || stat.size > ceiling) throw new Error('运行时文件类型或大小无效')
const handle = await fs.open(path, 'r')
try {
const before = await handle.stat()
if (before.ino !== stat.ino || before.dev !== stat.dev || !before.isFile() || before.size > ceiling) throw new Error('运行时文件读取前发生替换')
const bytes = await handle.readFile(), after = await handle.stat()
if (bytes.length !== before.size || bytes.length > ceiling || after.mtimeMs !== before.mtimeMs || after.size !== before.size) throw new Error('运行时文件读取期间发生变化')
return bytes
} finally { await handle.close() }
}
async function nonLinkDirectory(path: string): Promise<void> {
const stat = await fs.lstat(path)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('运行时目录不能是链接')
}
async function manifestFor(kind: SandboxToolchainKind): Promise<LocatedToolchain | null> {
const alias = join(root(), `${kind}.json`)
const stat = await fs.lstat(alias).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
if (!stat) return kind === 'git' && sandboxConfiguration().useBundledGit !== false ? bundledGit() : null
await nonLinkDirectory(root())
const active = JSON.parse((await regularBytes(alias, 4096)).toString('utf8')) as SandboxToolchainIdentity
if (active.kind !== kind || !uuid.test(active.id) || !hashPattern.test(active.manifestSha256)) throw new Error('运行时引用无效')
const directory = join(root(), 'objects', active.id)
await nonLinkDirectory(join(root(), 'objects')); await nonLinkDirectory(directory); await nonLinkDirectory(join(directory, 'files'))
const bytes = await regularBytes(join(directory, 'manifest.json'), ceilings.manifestBytes)
if (hash(bytes) !== active.manifestSha256) throw new Error('运行时清单摘要不匹配')
const manifest = validateManifest(bytes, active)
return { manifest, directory, identity: active, source: 'imported' }
}
function validateManifest(bytes: Buffer, active: SandboxToolchainIdentity): ToolchainManifest {
const kind = active.kind, manifest = JSON.parse(bytes.toString('utf8')) as ToolchainManifest
if (manifest.version !== 1 || manifest.id !== active.id || manifest.kind !== kind || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > ceilings.files
|| !Array.isArray(manifest.features) || manifest.features.some(feature => typeof feature !== 'string' || feature.length > 64)) throw new Error('运行时清单格式无效')
const seen = new Set<string>(); let total = 0
for (const file of manifest.files) {
const path = filePath(file.path), key = path.toLowerCase()
if (seen.has(key) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > ceilings.fileBytes || !hashPattern.test(file.sha256)) throw new Error('运行时清单文件身份无效')
seen.add(key); total += file.bytes
}
if (manifest.bytes !== total || total > ceilings.bytes || !seen.has(`${kind}.exe`)) throw new Error('运行时文件清单或容量无效')
return manifest
}
async function bundledGit(): Promise<LocatedToolchain | null> {
const resources = sandboxConfiguration().resourceDirectory
const componentPath = join(resources, 'manifest.json')
if (!await fs.lstat(componentPath).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null
await nonLinkDirectory(resources)
const component = JSON.parse((await regularBytes(componentPath, ceilings.manifestBytes)).toString('utf8')) as { bundledGit?: { manifestSha256?: string; version?: string } }
if (!component.bundledGit) return null
if (!hashPattern.test(component.bundledGit.manifestSha256 ?? '') || typeof component.bundledGit.version !== 'string') throw new Error('内置 Git 引用无效;请重新构建或安装应用')
const directory = join(resources, 'git')
await nonLinkDirectory(directory); await nonLinkDirectory(join(directory, 'files'))
const bytes = await regularBytes(join(directory, 'manifest.json'), ceilings.manifestBytes)
if (hash(bytes) !== component.bundledGit.manifestSha256) throw new Error('内置 Git 清单摘要不匹配;请重新构建或安装应用')
const raw = JSON.parse(bytes.toString('utf8')) as ToolchainManifest
if (!uuid.test(raw.id)) throw new Error('内置 Git 身份无效')
const identity: SandboxToolchainIdentity = { kind: 'git', id: raw.id, manifestSha256: hash(bytes) }
const manifest = validateManifest(bytes, identity)
if (manifest.distribution?.version !== component.bundledGit.version || !hashPattern.test(manifest.distribution.archiveSha256)) throw new Error('内置 Git 发行信息无效')
return { manifest, directory, identity, source: 'bundled' }
}
export async function listSandboxToolchains(): Promise<SandboxToolchainView[]> {
return Promise.all(kinds.map(async kind => {
try {
const value = await manifestFor(kind)
return value ? { kind, state: 'registered' as const, id: value.manifest.id, files: value.manifest.files.length, bytes: value.manifest.bytes, features: value.manifest.features, source: value.source, ...(value.manifest.distribution ? { version: value.manifest.distribution.version } : {}) }
: { kind, state: 'missing' as const, reason: kind === 'git' ? '内置 Git 不可用;请修复应用资源,或由维护者导入替代版本' : '尚未注册受控运行时;不会继承宿主 PATH' }
} catch (error) { return { kind, state: 'invalid' as const, reason: error instanceof Error ? error.message : String(error) } }
}))
}
export async function sandboxToolchainIdentity(kind: SandboxToolchainKind): Promise<SandboxToolchainIdentity | null> {
return (await manifestFor(kind))?.identity ?? null
}
/** Import explicit local runtime bytes. The source interpreter is never executed on the host. */
export async function importSandboxToolchain(kindInput: string, executableInput: string): Promise<SandboxToolchainView> {
const kind = kindValue(kindInput)
if (!isAbsolute(executableInput)) throw new Error('运行时源程序必须是显式绝对路径')
const selected = await fs.realpath(executableInput)
if (basename(selected).toLowerCase() !== `${kind}.exe`) throw new Error('运行时源程序名称不匹配')
const executable = kind === 'git' ? await gitRuntimeExecutable(selected) : selected
if (basename(executable).toLowerCase() !== `${kind}.exe`) throw new Error('运行时源程序名称不匹配')
const sourceRoot = dirname(executable), id = randomUUID(), directory = join(root(), 'objects', id), filesRoot = join(directory, 'files')
await fs.mkdir(filesRoot, { recursive: true, mode: 0o700 })
await nonLinkDirectory(root()); await nonLinkDirectory(join(root(), 'objects')); await nonLinkDirectory(directory); await nonLinkDirectory(filesRoot)
const manifest: ToolchainManifest = { version: 1, kind, id, createdAt: Date.now(), files: [], bytes: 0, features: [kind] }
async function put(destination: string, bytes: Buffer): Promise<void> {
const path = filePath(destination)
if (manifest.files.length >= ceilings.files || bytes.length > ceilings.fileBytes || manifest.bytes + bytes.length > ceilings.bytes) throw new Error('运行时超过受控导入容量上限')
const target = join(filesRoot, path)
await fs.mkdir(dirname(target), { recursive: true })
await fs.writeFile(target, bytes, { flag: 'wx', mode: 0o600 })
manifest.files.push({ path, bytes: bytes.length, sha256: hash(bytes) }); manifest.bytes += bytes.length
}
async function copyFile(source: string, destination: string): Promise<Buffer> {
if (!within(sourceRoot, source)) throw new Error('运行时导入源越界')
const bytes = await regularBytes(source, ceilings.fileBytes, true)
await put(destination, bytes)
return bytes
}
async function copyTree(source: string, destination: string, depth = 0): Promise<void> {
if (depth > 64) throw new Error('运行时目录层级过深')
await nonLinkDirectory(source)
for await (const entry of await fs.opendir(source)) {
if (entry.name === '__pycache__' || entry.name === '.git' || entry.name === 'site-packages' || kind === 'python' && ['test', 'tests', 'idlelib'].includes(entry.name)) continue
const path = join(source, entry.name), target = `${destination}/${entry.name}`
if (protectedPath(target) && target !== 'Lib/site-packages/pip/_vendor/certifi/cacert.pem') continue
if (entry.isSymbolicLink()) throw new Error('运行时源包含符号链接')
if (entry.isDirectory()) await copyTree(path, target, depth + 1)
else await copyFile(path, target)
}
}
const executableBytes = await copyFile(executable, `${kind}.exe`)
if (kind === 'node') {
const npm = join(sourceRoot, 'node_modules/npm')
if (await fs.stat(npm).catch(() => null)) {
const pkg = JSON.parse((await regularBytes(join(npm, 'package.json'), 1024 * 1024)).toString('utf8'))
if (pkg.name !== 'npm') throw new Error('npm 源目录身份无效')
await copyTree(npm, 'node_modules/npm')
await put('npm.cmd', Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\npm-cli.js" %*\r\n'))
await put('npx.cmd', Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0node_modules\\npm\\bin\\npx-cli.js" %*\r\n'))
manifest.features.push('npm', 'npx')
}
} else if (kind === 'python') {
for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) {
if (entry.isFile() && /^(?:python3\d*|vcruntime\d+(?:_\d+)?)\.dll$/i.test(entry.name)) await copyFile(join(sourceRoot, entry.name), entry.name)
}
if (!manifest.files.some(file => /^python3\d+\.dll$/i.test(file.path))) throw new Error('Python 源目录缺少版本 DLL')
await copyTree(join(sourceRoot, 'Lib'), 'Lib')
if (await fs.stat(join(sourceRoot, 'DLLs')).catch(() => null)) await copyTree(join(sourceRoot, 'DLLs'), 'DLLs')
const pip = join(sourceRoot, 'Lib/site-packages/pip')
if (await fs.stat(pip).catch(() => null)) { await copyTree(pip, 'Lib/site-packages/pip'); manifest.features.push('pip') }
} else {
await nonLinkDirectory(sourceRoot)
const seen = new Set<string>(['git.exe']), pending = [{ bytes: executableBytes, directory: sourceRoot }]
const addExecutable = async (path: string, name: string): Promise<boolean> => {
if (!await fs.lstat(path).catch(() => null)) return false
const bytes = await regularBytes(path, ceilings.fileBytes, true)
gitRuntimeImports(bytes)
await put(name, bytes); seen.add(name.toLowerCase()); pending.push({ bytes, directory: dirname(path) })
return true
}
const http = await addExecutable(join(sourceRoot, 'git-remote-http.exe'), 'git-remote-http.exe')
const https = await addExecutable(join(sourceRoot, 'git-remote-https.exe'), 'git-remote-https.exe')
for (const name of ['git-upload-pack.exe', 'git-receive-pack.exe']) await addExecutable(join(sourceRoot, name), name)
const usr = join(dirname(dirname(sourceRoot)), 'usr/bin')
const shell = await addExecutable(join(usr, 'sh.exe'), 'sh.exe')
const ssh = await addExecutable(join(usr, 'ssh.exe'), 'ssh.exe')
if (http && https) {
const curl = await regularBytes(join(sourceRoot, 'libcurl-4.dll'), ceilings.fileBytes, true)
await put('libcurl-4.dll', curl); seen.add('libcurl-4.dll'); pending.push({ bytes: curl, directory: sourceRoot })
}
while (pending.length) {
const image = pending.shift()!
for (const dll of gitRuntimeImports(image.bytes)) {
if (gitSystemDll(dll) || seen.has(dll)) continue
seen.add(dll)
if (seen.size > 128) throw new Error('Git DLL dependency closure exceeds the import limit')
await nonLinkDirectory(image.directory)
const dependency = await regularBytes(join(image.directory, dll), ceilings.fileBytes, true)
await put(dll, dependency)
pending.push({ bytes: dependency, directory: image.directory })
}
}
await put('empty-hooks/.keep', Buffer.alloc(0))
await put('empty-template/.keep', Buffer.alloc(0))
manifest.features.push('git-local', 'private-snapshot-repository')
if (http && https) manifest.features.push('git-http')
if (shell && ssh) manifest.features.push('git-ssh')
}
const bytes = Buffer.from(JSON.stringify(manifest) + '\n')
if (bytes.length > ceilings.manifestBytes) throw new Error('运行时清单过大')
await fs.writeFile(join(directory, 'manifest.json'), bytes, { flag: 'wx', mode: 0o600 })
const identity: SandboxToolchainIdentity = { kind, id, manifestSha256: hash(bytes) }, temporary = join(root(), `${kind}-${id}.tmp`)
try {
await fs.writeFile(temporary, JSON.stringify(identity) + '\n', { flag: 'wx', mode: 0o600 })
await fs.rename(temporary, join(root(), `${kind}.json`))
} finally { await fs.unlink(temporary).catch(() => undefined) }
return { kind, state: 'registered', id, files: manifest.files.length, bytes: manifest.bytes, features: manifest.features, source: 'imported' }
}
export async function stageSandboxToolchains(stage: string, requested: string[], signal?: AbortSignal): Promise<SandboxToolchainIdentity[]> {
if (!Array.isArray(requested) || requested.length > kinds.length || new Set(requested).size !== requested.length) throw new Error('沙盒运行时选择无效')
const identities: SandboxToolchainIdentity[] = []
for (const input of requested) {
assertActive(signal)
const kind = kindValue(input), value = await manifestFor(kind)
if (!value) throw new Error(`受控运行时 ${kind} 尚未注册;请用 sandbox:maintain toolchain-add 显式导入`)
const source = join(value.directory, 'files'), target = join(stage, 'tools', kind)
const checkedDirectories = new Set<string>([source])
for (const file of value.manifest.files) {
assertActive(signal)
const path = join(source, file.path)
let parent = dirname(path)
while (!checkedDirectories.has(parent)) {
if (!within(source, parent)) throw new Error('运行时目录越界')
await nonLinkDirectory(parent); checkedDirectories.add(parent); parent = dirname(parent)
}
const bytes = await regularBytes(path, ceilings.fileBytes)
if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error(`受控运行时文件摘要不匹配:${kind}/${file.path}`)
const destination = resolve(target, file.path)
if (!within(target, destination)) throw new Error('运行时目标越界')
await fs.mkdir(dirname(destination), { recursive: true })
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 })
}
identities.push(value.identity)
}
return identities
}
+277
View File
@@ -0,0 +1,277 @@
import { promises as fs } from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import { dirname, join, relative, resolve } from 'node:path'
import { sandboxConfiguration } from './configuration'
import { assertActive, resolveSandboxPath, within } from './policy'
import { NativeWorkspaceWriter } from './filesystem-writer'
export const digest = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
export interface FileChange { path: string; beforeHash: string | null; after: Buffer | null }
export interface DirectoryChange { path: string; before: boolean; after: boolean; createdIdentity?: string }
export interface SandboxCallIdentity { runId?: string; toolCallId?: string; sessionId?: string; policyRevision?: string; policyFingerprint?: string; authorizationId?: string }
export interface TransactionGuard { beforeApply(): Promise<void>; check(): Promise<void>; workspaceIdentity?: string; gitMetadata?: boolean }
export function fileChangeDigest(changes: FileChange[], directories: DirectoryChange[] = []): string {
return digest(Buffer.from(JSON.stringify({ files: changes.map(change => ({ path: change.path, beforeHash: change.beforeHash, afterHash: change.after === null ? null : digest(change.after) })), directories })))
}
interface TransactionEntry { path: string; beforeHash: string | null; afterHash: string | null; beforeFile?: string; afterFile?: string }
interface MutationIntent { path: string; nonce: string; beforeHash: string | null; afterHash: string | null }
interface TransactionManifest {
version: 1 | 2 | 3; id: string; workspaceRoot: string; workspaceIdentity?: string; createdAt: number; identity: SandboxCallIdentity
status: 'prepared' | 'applying' | 'committed' | 'rolled-back' | 'recovery-required'; entries: TransactionEntry[]
directories?: DirectoryChange[]
mutations?: MutationIntent[]
mutationJournal?: boolean
gitMetadata?: boolean
}
const locks = new Map<string, Promise<unknown>>()
export async function withWorkspaceLock<T>(root: string, action: () => Promise<T>): Promise<T> {
const key = process.platform === 'win32' ? resolve(root).toLowerCase() : resolve(root)
const previous = locks.get(key) ?? Promise.resolve()
const flight = previous.catch(() => undefined).then(action)
locks.set(key, flight)
try { return await flight } finally { if (locks.get(key) === flight) locks.delete(key) }
}
async function currentFile(writer: NativeWorkspaceWriter, file: string): Promise<Buffer | null> {
const result = await writer.inspect(file, true)
if (result.kind === 'missing') return null
if (result.kind !== 'file' || !result.bytes) throw new Error(`文件目标被目录替换:${file}`)
return result.bytes
}
async function currentDirectory(writer: NativeWorkspaceWriter, file: string): Promise<boolean> {
const result = await writer.inspect(file)
if (result.kind === 'missing') return false
if (result.kind !== 'directory') throw new Error(`目录目标被文件或链接替换:${file}`)
return true
}
const pathKey = (path: string): string => process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path)
const depth = (path: string): number => path.split(/[\\/]/).length
const transactionPath = (root: string, path: string, gitMetadata = false): Promise<string> => gitMetadata && path === '.git' ? Promise.resolve(resolve(root, '.git')) : resolveSandboxPath(root, path)
async function checkDirectoryContents(writer: NativeWorkspaceWriter, changes: DirectoryChange[], targetsToRemove: Set<string>, restoring = false): Promise<void> {
for (const change of changes) {
if (restoring ? change.before || !change.after : change.after || !change.before) continue
if (!await currentDirectory(writer, change.path)) continue
for (const child of await writer.list(change.path)) {
if (!targetsToRemove.has(pathKey(resolve(writer.root, change.path, child)))) throw new Error(`目录包含未授权或外部新增内容:${change.path}`)
}
}
}
async function durableFile(path: string, bytes: Buffer | string): Promise<void> {
const handle = await fs.open(path, 'wx', 0o600)
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
}
async function saveManifest(directory: string, manifest: TransactionManifest): Promise<void> {
const temporary = join(directory, `manifest-${randomUUID()}.tmp`)
await durableFile(temporary, JSON.stringify(manifest, null, 2) + '\n')
await fs.rename(temporary, join(directory, 'manifest.json'))
}
async function replace(writer: NativeWorkspaceWriter, file: string, expectedHash: string | null, bytes: Buffer | null, directory: string, manifest: TransactionManifest): Promise<void> {
const intent: MutationIntent = { path: file, nonce: randomUUID().replaceAll('-', ''), beforeHash: expectedHash, afterHash: bytes === null ? null : digest(bytes) }
;(manifest.mutations ??= []).push(intent)
await durableFile(join(directory, 'mutations', `${intent.nonce}.json`), JSON.stringify(intent) + '\n')
await writer.replace(file, expectedHash, bytes, intent.nonce)
}
async function cleanup(writer: NativeWorkspaceWriter, manifest: TransactionManifest): Promise<void> {
for (const intent of manifest.mutations ?? []) await writer.cleanup(intent.path, intent.nonce, intent.beforeHash, intent.afterHash)
}
/** Persist backups before any host write; validate the complete batch before applying its first entry. */
export async function commitChanges(rootInput: string, changes: FileChange[], identity: SandboxCallIdentity = {}, signal?: AbortSignal, guard?: TransactionGuard, directories: DirectoryChange[] = []): Promise<string> {
if (changes.length + directories.length > 10000) throw new Error('文件事务超过总项数上限')
if (changes.reduce((total, change) => total + (change.after?.length ?? 0), 0) > (guard?.gitMetadata ? 256 : 128) * 1024 * 1024) throw new Error('文件事务超过总容量上限')
const root = await fs.realpath(rootInput)
return withWorkspaceLock(root, async () => {
assertActive(signal)
const writer = await NativeWorkspaceWriter.create(root, guard?.workspaceIdentity, guard?.gitMetadata)
try {
const id = randomUUID(), directory = join(sandboxConfiguration().stateRoot, 'transactions', id)
await fs.mkdir(join(directory, 'mutations'), { recursive: true, mode: 0o700 })
const manifest: TransactionManifest = { version: 3, id, workspaceRoot: root, workspaceIdentity: writer.rootIdentity, createdAt: Date.now(), identity: { runId: identity.runId, toolCallId: identity.toolCallId, sessionId: identity.sessionId, policyRevision: identity.policyRevision, policyFingerprint: identity.policyFingerprint, authorizationId: identity.authorizationId }, status: 'prepared', entries: [], directories: [], mutations: [], mutationJournal: true }
if (guard?.gitMetadata) manifest.gitMetadata = true
const seen = new Set<string>()
for (const [index, change] of changes.entries()) {
const absolute = await transactionPath(root, change.path, guard?.gitMetadata)
const file = relative(root, absolute)
const key = process.platform === 'win32' ? file.toLowerCase() : file
if (!file || seen.has(key)) throw new Error('文件事务包含重复或根目录目标')
seen.add(key)
const before = await currentFile(writer, file)
if ((before === null ? null : digest(before)) !== change.beforeHash) throw new Error(`文件在隔离执行或审批期间发生变化:${file}`)
const entry: TransactionEntry = { path: file, beforeHash: change.beforeHash, afterHash: change.after === null ? null : digest(change.after) }
if (before !== null) { entry.beforeFile = `${index}.before`; await durableFile(join(directory, entry.beforeFile), before) }
if (change.after !== null) { entry.afterFile = `${index}.after`; await durableFile(join(directory, entry.afterFile), change.after) }
manifest.entries.push(entry)
}
for (const change of directories) {
const absolute = await transactionPath(root, change.path, guard?.gitMetadata), file = relative(root, absolute)
const key = process.platform === 'win32' ? file.toLowerCase() : file
if (!file || seen.has(key) || typeof change.before !== 'boolean' || typeof change.after !== 'boolean' || change.before === change.after) throw new Error('目录事务包含重复、根目录或无效目标')
seen.add(key)
if (await currentDirectory(writer, file) !== change.before) throw new Error(`目录在隔离执行或审批期间发生变化:${file}`)
manifest.directories!.push({ path: file, before: change.before, after: change.after })
}
// Direct write_file calls may omit missing parents; journal those creations too.
const parentChanges = new Map(manifest.directories!.map(change => [pathKey(resolve(root, change.path)), change]))
const checkedParents = new Set<string>()
for (const entry of manifest.entries.filter(entry => entry.afterHash !== null)) {
let parent = dirname(entry.path)
while (parent !== '.') {
const key = process.platform === 'win32' ? parent.toLowerCase() : parent
const absoluteKey = pathKey(resolve(root, parent)), explicit = parentChanges.get(absoluteKey)
if (explicit && !explicit.after || seen.has(key) && !explicit) throw new Error('文件父路径与事务条目冲突')
if (checkedParents.has(absoluteKey)) break
checkedParents.add(absoluteKey)
if (!explicit && !await currentDirectory(writer, parent)) {
const change = { path: parent, before: false, after: true }
manifest.directories!.push(change); parentChanges.set(absoluteKey, change); seen.add(key)
}
parent = dirname(parent)
}
}
if (manifest.entries.length + manifest.directories!.length > 10000) throw new Error('文件事务超过总项数上限')
const directoryEntries = manifest.directories ?? []
const removeTargets = new Set([...manifest.entries.filter(entry => entry.afterHash === null).map(entry => pathKey(resolve(root, entry.path))), ...directoryEntries.filter(entry => !entry.after).map(entry => pathKey(resolve(root, entry.path)))])
await checkDirectoryContents(writer, directoryEntries, removeTargets)
await saveManifest(directory, manifest)
const applied: TransactionEntry[] = []
const created: DirectoryChange[] = [], deleted: DirectoryChange[] = []
try {
assertActive(signal)
await guard?.beforeApply()
manifest.status = 'applying'; await saveManifest(directory, manifest)
for (const entry of directoryEntries.filter(entry => entry.after).sort((a, b) => depth(a.path) - depth(b.path))) {
assertActive(signal); await guard?.check()
if (await currentDirectory(writer, entry.path)) throw new Error('目录在提交前被外部创建')
entry.createdIdentity = await writer.mkdir(entry.path); created.push(entry)
await saveManifest(directory, manifest)
}
for (const [index, change] of changes.entries()) {
assertActive(signal)
await guard?.check()
const before = await currentFile(writer, manifest.entries[index].path)
if ((before === null ? null : digest(before)) !== change.beforeHash) throw new Error('文件在提交前发生变化')
// Include a lost native acknowledgement in compensation/recovery checks.
applied.push(manifest.entries[index])
await replace(writer, manifest.entries[index].path, change.beforeHash, change.after, directory, manifest)
}
for (const entry of directoryEntries.filter(entry => !entry.after).sort((a, b) => depth(b.path) - depth(a.path))) {
assertActive(signal); await guard?.check()
if (!await currentDirectory(writer, entry.path)) throw new Error('目录在提交前被外部删除')
await writer.rmdir(entry.path); deleted.push(entry)
}
assertActive(signal)
await guard?.check()
manifest.status = 'committed'; await saveManifest(directory, manifest)
return id
} catch (error) {
let recovered = writer.available
for (const entry of [...deleted].reverse()) {
try { if (!await currentDirectory(writer, entry.path)) await writer.mkdir(entry.path) }
catch { recovered = false }
}
for (const entry of [...applied].reverse()) {
try {
const current = await currentFile(writer, entry.path), hash = current === null ? null : digest(current)
if (hash === entry.beforeHash) continue
if (hash !== entry.afterHash && hash !== null) throw new Error('文件已被外部编辑')
const bytes = entry.beforeFile ? await fs.readFile(join(directory, entry.beforeFile)) : null
if (bytes && digest(bytes) !== entry.beforeHash) throw new Error('文件备份摘要不匹配')
await replace(writer, entry.path, hash, bytes, directory, manifest)
} catch { recovered = false }
}
try { await cleanup(writer, manifest) } catch { recovered = false }
for (const entry of [...created].reverse()) {
try { if (await currentDirectory(writer, entry.path)) await writer.rmdir(entry.path) }
catch { recovered = false }
}
manifest.status = recovered ? 'rolled-back' : 'recovery-required'
await saveManifest(directory, manifest)
if (!recovered) throw Object.assign(new Error(`文件事务 ${id} 未完整回滚,请使用沙盒维护命令恢复`), { transactionId: id, recoveryRequired: true, cause: error })
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { transactionId: id, recoveryRequired: false })
}
} finally { await writer.close() }
})
}
/** Explicit recovery refuses to overwrite a subsequent external edit. Repeated recovery is harmless. */
export async function restoreTransaction(id: string, expectedWorkspaceRoot: string): Promise<void> {
if (!/^[0-9a-f-]{36}$/.test(id)) throw new Error('文件事务标识无效')
const root = await fs.realpath(expectedWorkspaceRoot)
await withWorkspaceLock(root, async () => {
const directory = join(sandboxConfiguration().stateRoot, 'transactions', id)
const manifest = JSON.parse(await fs.readFile(join(directory, 'manifest.json'), 'utf8')) as TransactionManifest
if (![1, 2, 3].includes(manifest.version) || manifest.id !== id || resolve(manifest.workspaceRoot) !== root || !Array.isArray(manifest.entries) || manifest.entries.length > 10_000 || manifest.version >= 2 && !Array.isArray(manifest.directories)
|| manifest.version === 3 && (typeof manifest.workspaceIdentity !== 'string' || !/^[a-f0-9]{16}:[a-f0-9]{32}$/.test(manifest.workspaceIdentity) || !Array.isArray(manifest.mutations))) throw new Error('事务记录与工作区不匹配')
if (manifest.status === 'rolled-back') return
if (!['committed', 'recovery-required', 'applying', 'prepared'].includes(manifest.status)) throw new Error('事务状态无效')
if (manifest.mutationJournal === true) {
const journal = join(directory, 'mutations'), stat = await fs.lstat(journal)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('事务变更日志目录无效')
const records = await fs.readdir(journal, { withFileTypes: true })
if (records.length > 50000) throw new Error('事务变更日志过多')
const merged = new Map((manifest.mutations ?? []).map(intent => [intent.nonce, intent]))
for (const record of records) {
if (!record.isFile() || record.isSymbolicLink() || !/^[a-f0-9]{32}\.json$/.test(record.name)) throw new Error('事务变更日志文件无效')
const file = join(journal, record.name), fileStat = await fs.lstat(file)
if (fileStat.nlink !== 1 || fileStat.size > 4096) throw new Error('事务变更日志类型或大小无效')
const intent = JSON.parse(await fs.readFile(file, 'utf8')) as MutationIntent
if (intent.nonce !== record.name.slice(0, -5) || merged.has(intent.nonce) && JSON.stringify(merged.get(intent.nonce)) !== JSON.stringify(intent)) throw new Error('事务变更日志不一致')
merged.set(intent.nonce, intent)
}
manifest.mutations = [...merged.values()]
}
if (manifest.mutations && (manifest.mutations.length > 50000 || manifest.mutations.some(intent => !intent || typeof intent.path !== 'string' || !/^[a-f0-9]{32}$/.test(intent.nonce)
|| ![intent.beforeHash, intent.afterHash].every(value => value === null || typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))))) throw new Error('事务变更日志无效')
const writer = await NativeWorkspaceWriter.create(root, manifest.workspaceIdentity, manifest.gitMetadata === true)
try {
const pending: Array<{ path: string; bytes: Buffer | null; expectedHash: string | null }> = []
const seen = new Set<string>()
for (const entry of manifest.entries) {
const validHash = (value: unknown): boolean => value === null || typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
if (!entry || typeof entry.path !== 'string' || !validHash(entry.beforeHash) || !validHash(entry.afterHash) || (entry.beforeHash !== null && typeof entry.beforeFile !== 'string')) throw new Error('事务条目无效')
await transactionPath(root, entry.path, manifest.gitMetadata)
const key = pathKey(resolve(root, entry.path))
if (key === pathKey(root) || seen.has(key)) throw new Error('恢复条目重复或指向根目录')
seen.add(key)
const current = await currentFile(writer, entry.path), hash = current === null ? null : digest(current)
if (hash === entry.beforeHash) continue
const interruptedHandoff = hash === null && manifest.status !== 'committed' && manifest.mutations?.some(intent => intent.path === entry.path && intent.beforeHash !== null && intent.afterHash !== null)
if (hash !== entry.afterHash && !interruptedHandoff) throw new Error(`恢复会覆盖外部修改:${entry.path}`)
let bytes: Buffer | null = null
if (entry.beforeFile) {
const backup = resolve(directory, entry.beforeFile)
if (!within(directory, backup) || dirname(backup) !== directory) throw new Error('备份路径无效')
if (!(await fs.lstat(backup)).isFile() || (await fs.lstat(backup)).isSymbolicLink()) throw new Error('备份文件类型无效')
bytes = await fs.readFile(backup)
if (digest(bytes) !== entry.beforeHash) throw new Error('文件备份摘要不匹配')
}
pending.push({ path: entry.path, bytes, expectedHash: hash })
}
const directories = manifest.directories ?? []
if (directories.length > 10_000) throw new Error('目录恢复条目过多')
for (const entry of directories) {
if (!entry || typeof entry.path !== 'string' || typeof entry.before !== 'boolean' || typeof entry.after !== 'boolean' || entry.before === entry.after) throw new Error('目录恢复条目无效')
const path = await transactionPath(root, entry.path, manifest.gitMetadata), key = pathKey(path)
if (key === pathKey(root) || seen.has(key)) throw new Error('目录恢复条目重复或指向根目录')
seen.add(key)
await currentDirectory(writer, entry.path)
if (entry.createdIdentity && (!/^[a-f0-9]{16}:[a-f0-9]{32}$/.test(entry.createdIdentity) || !entry.before && (await writer.inspect(entry.path)).kind === 'directory' && (await writer.inspect(entry.path)).identity !== entry.createdIdentity)) throw new Error('新建目录身份已变化,拒绝恢复')
}
for (const intent of manifest.mutations ?? []) {
if (!manifest.entries.some(entry => entry.path === intent.path && [null, entry.beforeHash, entry.afterHash].includes(intent.beforeHash) && [null, entry.beforeHash, entry.afterHash].includes(intent.afterHash))) throw new Error('事务变更日志与备份条目不匹配')
}
await cleanup(writer, manifest)
const removeTargets = new Set([...manifest.entries.filter(entry => entry.beforeHash === null).map(entry => pathKey(resolve(root, entry.path))), ...directories.filter(entry => !entry.before).map(entry => pathKey(resolve(root, entry.path)))])
await checkDirectoryContents(writer, directories, removeTargets, true)
manifest.version = 3; manifest.workspaceIdentity = writer.rootIdentity; manifest.directories ??= []; manifest.mutations ??= []; manifest.mutationJournal = true
await fs.mkdir(join(directory, 'mutations'), { recursive: true, mode: 0o700 })
manifest.status = 'recovery-required'; await saveManifest(directory, manifest)
for (const entry of directories.filter(entry => entry.before).sort((a, b) => depth(a.path) - depth(b.path))) {
if (!await currentDirectory(writer, entry.path)) await writer.mkdir(entry.path)
}
for (const entry of pending) await replace(writer, entry.path, entry.expectedHash, entry.bytes, directory, manifest)
await cleanup(writer, manifest)
for (const entry of directories.filter(entry => !entry.before).sort((a, b) => depth(b.path) - depth(a.path))) {
if (await currentDirectory(writer, entry.path)) await writer.rmdir(entry.path)
}
manifest.status = 'rolled-back'; await saveManifest(directory, manifest)
} finally { await writer.close() }
})
}
+86
View File
@@ -0,0 +1,86 @@
import { promises as fs, constants } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import type { SandboxEffectivePolicy } from '@shared/sandbox'
import type { FileChange, DirectoryChange } from './transactions'
import { digest } from './transactions'
import { sandboxConfiguration } from './configuration'
import { assertActive, resolveSandboxPath, within } from './policy'
import { sandboxPathDenied } from './policy-store'
export type Snapshot = Map<string, { hash: string; size: number }>
const excludedDirectories = new Set(['.git', '.ycode', '.tmp', 'node_modules', '.venv', 'venv', '__pycache__'])
export async function regularBytes(file: string, fileLimit: number): Promise<Buffer> {
const before = await fs.lstat(file)
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > fileLimit) throw new Error('快照包含链接、特殊文件或超过策略上限的文件')
const handle = await fs.open(file, constants.O_RDONLY)
try {
const opened = await handle.stat()
if (opened.ino !== before.ino || opened.dev !== before.dev || opened.nlink !== 1 || opened.size !== before.size) throw new Error('文件在读取前发生变化')
const bytes = await handle.readFile()
const after = await handle.stat()
if (bytes.length !== before.size || after.mtimeMs !== opened.mtimeMs || after.size !== opened.size) throw new Error('文件在快照读取期间发生变化')
return bytes
} finally { await handle.close() }
}
export async function snapshot(source: string, target: string | undefined, policy: SandboxEffectivePolicy, signal?: AbortSignal): Promise<{ files: Snapshot; directories: Set<string>; excluded: number }> {
const files: Snapshot = new Map()
const directories = new Set<string>()
let totalBytes = 0, excluded = 0, entries = 0
const stateRoot = resolve(sandboxConfiguration().stateRoot)
async function walk(directory: string): Promise<void> {
for await (const entry of await fs.opendir(directory)) {
assertActive(signal)
if (++entries > policy.limits.files) throw new Error('工作区超过沙盒快照的总项数上限')
const sourcePath = join(directory, entry.name), file = relative(source, sourcePath)
if (within(stateRoot, sourcePath) || sandboxPathDenied(policy, sourcePath) || entry.isSymbolicLink() || entry.isDirectory() && excludedDirectories.has(entry.name.toLowerCase())) { excluded++; continue }
await resolveSandboxPath(source, file)
if (entry.isDirectory()) { directories.add(file); if (target) await fs.mkdir(join(target, file), { recursive: true }); await walk(sourcePath) }
else if (entry.isFile()) {
if (files.size >= policy.limits.files) throw new Error('工作区超过沙盒快照的文件数量上限')
const bytes = await regularBytes(sourcePath, policy.limits.fileBytes)
totalBytes += bytes.length
if (totalBytes > policy.limits.bytes) throw new Error('工作区超过沙盒快照的容量上限')
if (target) {
await fs.mkdir(dirname(join(target, file)), { recursive: true })
await fs.writeFile(join(target, file), bytes, { flag: 'wx' })
}
files.set(file, { hash: digest(bytes), size: bytes.length })
} else throw new Error('工作区包含不支持的特殊文件')
}
}
await walk(source)
return { files, directories, excluded }
}
export async function stageChanges(stageRoot: string, before: Snapshot, beforeDirectories: Set<string>, policy: SandboxEffectivePolicy, nodeDependencies: boolean, signal?: AbortSignal): Promise<{ files: FileChange[]; directories: DirectoryChange[] }> {
const after = new Map<string, Buffer>(), changes: FileChange[] = []
const directories = new Set<string>()
let totalBytes = 0, entries = 0
async function walk(directory: string): Promise<void> {
for await (const entry of await fs.opendir(directory)) {
assertActive(signal)
if (++entries > policy.limits.files) throw new Error('沙盒产物超过总项数上限')
const absolute = join(directory, entry.name), file = relative(stageRoot, absolute)
if (nodeDependencies && file === 'node_modules') continue
if (sandboxPathDenied(policy, join(policy.workspaceRoot, file)) || entry.isSymbolicLink()) throw new Error('沙盒产生了受保护路径或链接;未写回工作区')
await resolveSandboxPath(stageRoot, file)
if (entry.isDirectory()) { directories.add(file); await walk(absolute) }
else if (entry.isFile()) {
if (after.size >= policy.limits.files) throw new Error('沙盒产物超过文件数量上限')
const bytes = await regularBytes(absolute, policy.limits.fileBytes)
totalBytes += bytes.length
if (totalBytes > policy.limits.bytes) throw new Error('沙盒产物超过容量上限')
after.set(file, bytes)
} else throw new Error('沙盒产生了不支持的特殊文件')
}
}
await walk(stageRoot)
const directoryChanges: DirectoryChange[] = []
for (const directory of beforeDirectories) if (!directories.has(directory)) directoryChanges.push({ path: directory, before: true, after: false })
for (const directory of directories) if (!beforeDirectories.has(directory)) directoryChanges.push({ path: directory, before: false, after: true })
for (const [file, bytes] of after) {
const old = before.get(file)
if (old?.hash !== digest(bytes)) changes.push({ path: file, beforeHash: old?.hash ?? null, after: bytes })
}
for (const [file, old] of before) if (!after.has(file)) changes.push({ path: file, beforeHash: old.hash, after: null })
return { files: changes, directories: directoryChanges }
}
+147
View File
@@ -0,0 +1,147 @@
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 }
}
}
+419
View File
@@ -0,0 +1,419 @@
import { promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { SandboxChangedFile, SandboxEffectivePolicy, SandboxWorkspaceView } from '@shared/sandbox'
import { sandboxConfiguration } from './configuration'
import { assertActive, protectedPath, within } from './policy'
import { assertPolicyCurrent, sandboxPathDenied } from './policy-store'
import { digest, type DirectoryChange, type FileChange } from './transactions'
import { regularBytes, snapshot, stageChanges, type Snapshot } from './workspace-files'
import { verifiedBinary } from './native-component'
import { NativeWorkspaceReader } from './filesystem-reader'
import { releaseRunLease } from './maintenance'
export interface WorkspaceEntry { path: string; kind: 'file' | 'directory'; hash?: string; size?: number }
export interface WorkspaceRevision {
version: 1; id: string; workspaceRoot: string; workspaceIdentity: string; sessionId: string
policyFingerprint: string; policyRevision: string; createdAt: number; base: WorkspaceEntry[]; tree: WorkspaceEntry[]
physicalIdentity: string; excluded: number; lastTransactionId?: string
previousRevision?: string; lastRunId?: string; lastRecoveredRunId?: string
}
interface RevisionIdentity { id: string; sha256: string }
interface Checkout { version: 1; runId: string; revision: string; physicalIdentity: string }
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
const hash = /^[a-f0-9]{64}$/
const fileId = /^[a-f0-9]{16}:[a-f0-9]{32}$/
const manifestBytes = 8 * 1024 * 1024
const key = (value: string): string => process.platform === 'win32' ? value.toLowerCase() : value
const same = (a?: WorkspaceEntry, b?: WorkspaceEntry): boolean => a?.kind === b?.kind && a?.hash === b?.hash
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 depth = (path: string): number => path.split('/').length
const excluded = new Set(['node_modules', '.tmp', '.venv', 'venv', '__pycache__'])
export function workspaceKey(workspace: string, sessionId: string): string {
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200 || /[\x00-\x1f]/.test(sessionId)) throw new Error('长期工作区需要有效的宿主会话身份')
return digest(Buffer.from(`${key(workspace)}\0${sessionId}`))
}
async function directory(path: string): Promise<void> {
const stat = await fs.lstat(path)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('长期工作区状态目录不能是链接或普通文件')
}
async function exists(path: string): Promise<boolean> {
return !!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
}
export async function workspaceRecord<T>(path: string, maximum = 8 * 1024 * 1024): Promise<T> {
return JSON.parse((await regularBytes(path, maximum)).toString('utf8')) as T
}
export async function saveWorkspaceRecord(path: string, value: unknown, replace = true): Promise<void> {
const temporary = `${path}.${randomUUID()}.tmp`, handle = await fs.open(temporary, 'wx', 0o600)
try { await handle.writeFile(JSON.stringify(value) + '\n'); await handle.sync() } finally { await handle.close() }
try {
if (!replace && await exists(path)) throw new Error('长期工作区记录已存在')
for (let attempt = 0; ; attempt++) {
try { await fs.rename(temporary, path); break }
catch (error) {
// Windows readers may briefly deny replacement of an otherwise valid record.
if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code ?? '') || attempt >= 12) throw error
await new Promise(done => setTimeout(done, (attempt + 1) * 10))
}
}
} finally { await fs.unlink(temporary).catch(() => undefined) }
}
export async function sealWorkspace(path: string): Promise<string> {
const binary = await verifiedBinary(true, true)
const child = spawn(binary, ['--seal-workspace', path], { cwd: dirname(binary), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = '', error = '', overflow = false
child.stdout.on('data', (bytes: Buffer) => { output += bytes.toString('utf8'); if (output.length > 4096) { overflow = true; child.kill() } })
child.stderr.on('data', (bytes: Buffer) => { error = (error + bytes.toString('utf8')).slice(-2048) })
const timer = setTimeout(() => child.kill(), 120_000)
try {
const exit = await new Promise<number | null>((done, reject) => { child.once('error', reject); child.once('close', done) })
if (exit !== 0 || overflow) throw new Error(`长期工作区权限封存失败:${error}`)
const value = JSON.parse(output) as { protocol: number; sealed: boolean; rootIdentity: string }
if (value.protocol !== 1 || value.sealed !== true || !fileId.test(value.rootIdentity)) throw new Error('长期工作区封存报告无效')
return value.rootIdentity
} finally { clearTimeout(timer) }
}
function validateTree(tree: WorkspaceEntry[], policy: SandboxEffectivePolicy): void {
if (!Array.isArray(tree) || tree.length > policy.limits.files) throw new Error('长期工作区总项数超过上限')
let bytes = 0; const seen = new Map<string, WorkspaceEntry>()
for (const entry of tree) {
if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.length > 1024 || isAbsolute(entry.path) || entry.path.includes('\\')
|| entry.path.split('/').some(part => !part || part === '.' || part === '..' || excluded.has(part.toLowerCase())) || protectedPath(entry.path)
|| sandboxPathDenied(policy, join(policy.workspaceRoot, entry.path)) || seen.has(key(entry.path)) || !['file', 'directory'].includes(entry.kind)) throw new Error('长期工作区包含无效、重复或被策略拒绝的路径')
if (entry.kind === 'file') {
if (!hash.test(entry.hash ?? '') || !Number.isSafeInteger(entry.size) || entry.size! < 0 || entry.size! > policy.limits.fileBytes) throw new Error('长期工作区文件身份或容量无效')
bytes += entry.size!
}
seen.set(key(entry.path), entry)
}
if (bytes > policy.limits.bytes) throw new Error('长期工作区容量超过上限')
for (const entry of tree) {
let parent = dirname(entry.path).replaceAll('\\', '/')
while (parent !== '.') {
if (seen.get(key(parent))?.kind !== 'directory') throw new Error('长期工作区父目录清单不完整')
parent = dirname(parent).replaceAll('\\', '/')
}
}
}
function fromSnapshot(value: { files: Snapshot; directories: Set<string> }): WorkspaceEntry[] {
return order([...value.files].map(([path, file]): WorkspaceEntry => ({ path: path.replaceAll('\\', '/'), kind: 'file', hash: file.hash, size: file.size }))
.concat([...value.directories].map(path => ({ path: path.replaceAll('\\', '/'), kind: 'directory' as const }))))
}
export function treeChanges(before: WorkspaceEntry[], after: WorkspaceEntry[]): SandboxChangedFile[] {
const base = index(before), next = index(after), changes: SandboxChangedFile[] = []
for (const name of new Set([...base.keys(), ...next.keys()])) {
const old = base.get(name), current = next.get(name)
if (same(old, current)) continue
if (old && current && old.kind !== current.kind) throw new Error('长期工作区暂不支持文件/目录互换')
changes.push({ path: current?.path ?? old!.path, operation: current?.kind === 'directory' ? 'mkdir' : old?.kind === 'directory' ? 'rmdir' : !current ? 'delete' : !old ? 'create' : 'update',
beforeHash: old?.hash ?? null, afterHash: current?.hash ?? null })
}
return changes.sort((a, b) => a.path.localeCompare(b.path, 'en'))
}
/** File and ancestor conflicts are decided before touching the retained worktree. */
export function mergeWorkspaceTrees(baseTree: WorkspaceEntry[], draftTree: WorkspaceEntry[], hostTree: WorkspaceEntry[]): { tree: WorkspaceEntry[]; conflicts: string[] } {
const base = index(baseTree), draft = index(draftTree), host = index(hostTree), conflicts = new Set<string>(), merged = new Map(draft)
const draftChanged = new Set([...new Set([...base.keys(), ...draft.keys()])].filter(name => !same(base.get(name), draft.get(name))))
for (const name of new Set([...base.keys(), ...host.keys()])) {
const before = base.get(name), current = host.get(name)
if (same(before, current)) continue
if (before && current && before.kind !== current.kind) conflicts.add(current.path)
if (draftChanged.has(name) && !same(draft.get(name), current)) conflicts.add(current?.path ?? before!.path)
if (before?.kind === 'directory' && current?.kind !== 'directory' && [...draftChanged].some(path => path.startsWith(`${name}/`))) conflicts.add(before.path)
const parts = name.split('/')
for (let length = 1; length < parts.length; length++) {
const parent = parts.slice(0, length).join('/')
if (draftChanged.has(parent) && draft.get(parent)?.kind !== 'directory' && current) conflicts.add(current.path)
}
if (current) merged.set(name, current); else merged.delete(name)
}
return { tree: order([...merged.values()]), conflicts: [...conflicts].sort() }
}
export class PersistentWorkspace {
revision: WorkspaceRevision | null = null
checkedOut = false
interrupted = false
conflicts: string[] = []
readonly root: string
readonly working: string
private lock?: fs.FileHandle
private lockNonce = randomUUID()
private checkout?: Checkout
private constructor(readonly policy: SandboxEffectivePolicy, readonly sessionId: string, readonly rootIdentity: string, readonly namespace: string) {
this.root = join(sandboxConfiguration().stateRoot, 'workspaces', namespace)
this.working = join(this.root, 'worktree')
}
static async peek(policy: SandboxEffectivePolicy, sessionId: string, rootIdentity: string): Promise<SandboxWorkspaceView> {
const store = new PersistentWorkspace(policy, sessionId, rootIdentity, workspaceKey(policy.workspaceRoot, sessionId))
if (!await exists(store.root)) return store.view()
await directory(store.root)
if (await exists(join(store.root, 'workspace.lock'))) return { mode: 'persistent', state: 'busy', sessionId, changes: [], reason: '草稿正在执行或维护;异常退出后可显式 recover' }
await store.load()
store.interrupted = await exists(join(store.root, 'checkout.json')) || await exists(join(store.root, 'mutation.json')) || await exists(join(store.root, 'commit-intent.json'))
return store.view()
}
static async open(policy: SandboxEffectivePolicy, sessionId: string, rootIdentity: string, reclaim = false): Promise<PersistentWorkspace> {
const store = new PersistentWorkspace(policy, sessionId, rootIdentity, workspaceKey(policy.workspaceRoot, sessionId))
const created = !await exists(store.root)
await fs.mkdir(store.root, { recursive: true, mode: 0o700 })
for (const path of [sandboxConfiguration().stateRoot, join(sandboxConfiguration().stateRoot, 'workspaces'), store.root]) await directory(path)
if (created) await sealWorkspace(store.root)
for (const name of ['blobs', 'revisions', 'archives', 'actions']) { await fs.mkdir(join(store.root, name), { recursive: true }); await directory(join(store.root, name)) }
const lockPath = join(store.root, 'workspace.lock')
if (reclaim && await exists(lockPath)) {
const bytes = await regularBytes(lockPath, 4096), lock = JSON.parse(bytes.toString('utf8')) as { pid: number; nonce: string }
if (!Number.isSafeInteger(lock.pid) || lock.pid < 1 || !uuid.test(lock.nonce)) throw new Error('长期工作区维护锁损坏')
try { process.kill(lock.pid, 0); throw new Error('长期工作区仍由活跃主进程持有') }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
if (!(await regularBytes(lockPath, 4096)).equals(bytes)) throw new Error('长期工作区维护锁已变化')
await fs.unlink(lockPath)
}
store.lock = await fs.open(lockPath, 'wx', 0o600).catch(error => { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('长期工作区正在使用;异常退出后请显式 recover'); throw error })
try {
await store.lock.writeFile(JSON.stringify({ pid: process.pid, nonce: store.lockNonce }) + '\n'); await store.lock.sync()
await store.load()
store.interrupted = await exists(join(store.root, 'checkout.json')) || await exists(join(store.root, 'mutation.json')) || await exists(join(store.root, 'commit-intent.json'))
return store
} catch (error) { await store.close(); throw error }
}
private async load(): Promise<void> {
const active = join(this.root, 'active.json')
if (!await exists(active)) return
const identity = await workspaceRecord<RevisionIdentity>(active, 4096)
if (!uuid.test(identity.id) || !hash.test(identity.sha256)) throw new Error('长期工作区版本引用无效')
const bytes = await regularBytes(join(this.root, 'revisions', `${identity.id}.json`), manifestBytes)
if (digest(bytes) !== identity.sha256) throw new Error('长期工作区版本摘要不匹配')
const value = JSON.parse(bytes.toString('utf8')) as WorkspaceRevision
if (value.version !== 1 || value.id !== identity.id || key(value.workspaceRoot) !== key(this.policy.workspaceRoot) || value.sessionId !== this.sessionId || !fileId.test(value.workspaceIdentity)
|| !fileId.test(value.physicalIdentity) || !hash.test(value.policyFingerprint) || !Number.isSafeInteger(value.createdAt)
|| !Array.isArray(value.base) || !Array.isArray(value.tree) || value.base.length > 10000 || value.tree.length > 10000 || !Number.isSafeInteger(value.excluded) || value.excluded < 0) throw new Error('长期工作区版本归属无效')
this.revision = value
}
assertUsable(): void {
if (this.revision && (this.revision.policyFingerprint !== this.policy.fingerprint || this.revision.workspaceIdentity !== this.rootIdentity)) throw new Error('长期工作区策略或宿主根身份已变化,请先 discard;旧草稿不会自动复用')
}
async assertCurrent(signal?: AbortSignal): Promise<void> {
assertActive(signal); await assertPolicyCurrent(this.policy)
if ((await NativeWorkspaceReader.create(await verifiedBinary(), this.policy, signal)).rootIdentity !== this.rootIdentity) throw new Error('长期工作区宿主根身份已变化')
this.assertUsable()
}
async assertIdle(): Promise<void> {
if (await exists(join(this.root, 'checkout.json')) || await exists(join(this.root, 'mutation.json')) || await exists(join(this.root, 'commit-intent.json'))) throw new Error('长期工作区有中断或未确定结果,请先 recover')
}
private async blob(entry: WorkspaceEntry): Promise<Buffer> {
const bytes = await regularBytes(join(this.root, 'blobs', entry.hash!), this.policy.limits.fileBytes)
if (digest(bytes) !== entry.hash || bytes.length !== entry.size) throw new Error('长期工作区文件内容摘要不匹配')
return bytes
}
private async retain(entry: WorkspaceEntry, bytes: Buffer): Promise<void> {
if (digest(bytes) !== entry.hash || bytes.length !== entry.size) throw new Error('长期工作区文件在保存前变化')
const target = join(this.root, 'blobs', entry.hash!)
if (await exists(target)) { await this.blob(entry); return }
const temporary = `${target}.${randomUUID()}.tmp`, handle = await fs.open(temporary, 'wx', 0o600)
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
try { await fs.rename(temporary, target) } finally { await fs.unlink(temporary).catch(() => undefined) }
}
private async capture(path: string, nodeDependencies = false): Promise<WorkspaceEntry[]> {
const delta = await stageChanges(path, new Map(), new Set(), this.policy, nodeDependencies)
const tree: WorkspaceEntry[] = delta.files.map(file => ({ path: file.path.replaceAll('\\', '/'), kind: 'file', hash: digest(file.after!), size: file.after!.length }))
tree.push(...delta.directories.map(entry => ({ path: entry.path.replaceAll('\\', '/'), kind: 'directory' as const })))
order(tree); validateTree(tree, this.policy)
const contents = new Map(delta.files.map(file => [key(file.path.replaceAll('\\', '/')), file.after!]))
for (const entry of tree) if (entry.kind === 'file') await this.retain(entry, contents.get(key(entry.path))!)
return tree
}
private async publish(value: Omit<WorkspaceRevision, 'id' | 'createdAt'>): Promise<void> {
const revision: WorkspaceRevision = { ...value, previousRevision: this.revision?.id, id: randomUUID(), createdAt: Date.now() }
const bytes = Buffer.from(JSON.stringify(revision) + '\n')
if (bytes.length > manifestBytes) throw new Error('长期工作区版本清单超过 8 MiB 上限')
const path = join(this.root, 'revisions', `${revision.id}.json`), handle = await fs.open(path, 'wx', 0o600)
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
await saveWorkspaceRecord(join(this.root, 'active.json'), { id: revision.id, sha256: digest(bytes) })
this.revision = revision
}
async initialize(): Promise<void> {
await this.assertIdle(); this.assertUsable()
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')
await fs.mkdir(this.working)
const initial = await snapshot(this.policy.workspaceRoot, this.working, this.policy)
const tree = await this.capture(this.working), physicalIdentity = await sealWorkspace(this.working)
await this.assertCurrent()
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 })
}
async verifyPhysical(): Promise<void> {
if (!this.revision) throw new Error('长期工作区尚未初始化')
await directory(this.working)
const tree = await this.capture(this.working)
if (JSON.stringify(tree) !== JSON.stringify(this.revision.tree)) throw new Error('长期工作区物理文件与已保存版本不一致,请先 recover 或 discard')
const identity = await sealWorkspace(this.working)
if (identity !== this.revision.physicalIdentity) throw new Error('长期工作区物理目录身份已变化')
}
async synchronize(): Promise<void> {
await this.assertCurrent(); await this.initialize(); await this.verifyPhysical()
const current = await snapshot(this.policy.workspaceRoot, undefined, this.policy), host = fromSnapshot(current)
validateTree(host, this.policy)
const merged = mergeWorkspaceTrees(this.revision!.base, this.revision!.tree, host)
this.conflicts = merged.conflicts
if (merged.conflicts.length) throw new Error(`长期工作区与宿主编辑冲突:${merged.conflicts.slice(0, 20).join('、')}`)
validateTree(merged.tree, this.policy)
const hostIndex = index(host), prior = index(this.revision!.tree)
for (const entry of merged.tree) if (entry.kind === 'file' && !same(prior.get(key(entry.path)), entry)) {
const bytes = await regularBytes(join(this.policy.workspaceRoot, hostIndex.get(key(entry.path))!.path), this.policy.limits.fileBytes)
await this.retain(entry, bytes)
}
if (JSON.stringify(host) !== JSON.stringify(this.revision!.base) || JSON.stringify(merged.tree) !== JSON.stringify(this.revision!.tree)) {
await saveWorkspaceRecord(join(this.root, 'mutation.json'), { version: 1, revision: this.revision!.id }, false)
try {
await this.applyTree(this.working, this.revision!.tree, merged.tree)
await this.assertCurrent()
await this.publish({ ...this.revision!, base: host, tree: merged.tree, excluded: current.excluded })
await fs.unlink(join(this.root, 'mutation.json'))
} catch (error) { this.interrupted = true; throw error }
}
}
private async applyTree(path: string, before: WorkspaceEntry[], after: WorkspaceEntry[]): Promise<void> {
const old = index(before), next = index(after)
for (const entry of before.filter(entry => entry.kind === 'file')) if (!next.has(key(entry.path))) await fs.unlink(join(path, entry.path))
for (const entry of before.filter(entry => entry.kind === 'directory').sort((a, b) => depth(b.path) - depth(a.path))) if (!next.has(key(entry.path))) await fs.rmdir(join(path, entry.path))
for (const entry of after.filter(entry => entry.kind === 'directory').sort((a, b) => depth(a.path) - depth(b.path))) if (!old.has(key(entry.path))) await fs.mkdir(join(path, entry.path))
for (const entry of after.filter(entry => entry.kind === 'file')) if (!same(old.get(key(entry.path)), entry)) await fs.writeFile(join(path, entry.path), await this.blob(entry))
}
async checkoutInto(stage: string, runId: string): Promise<{ files: Snapshot; directories: Set<string>; excluded: number }> {
if (!this.revision || !uuid.test(runId) || resolve(stage) !== resolve(sandboxConfiguration().stateRoot, 'runs', runId, 'stage')) throw new Error('长期工作区执行目标无效')
await this.assertIdle()
this.checkout = { version: 1, runId, revision: this.revision.id, physicalIdentity: this.revision.physicalIdentity }
await saveWorkspaceRecord(join(this.root, 'checkout.json'), this.checkout, false)
this.checkedOut = true
await fs.rmdir(join(stage, 'workspace'))
await fs.rename(this.working, join(stage, 'workspace'))
return { files: new Map(this.revision.tree.filter(entry => entry.kind === 'file').map(entry => [entry.path.replaceAll('/', '\\'), { hash: entry.hash!, size: entry.size! }])),
directories: new Set(this.revision.tree.filter(entry => entry.kind === 'directory').map(entry => entry.path.replaceAll('/', '\\'))), excluded: this.revision.excluded }
}
async finish(stage: string, nodeDependencies: boolean, signal?: AbortSignal): Promise<void> {
if (!this.checkedOut || !this.revision) throw new Error('长期工作区没有活动执行')
await this.assertCurrent(signal)
const path = join(stage, 'workspace'), tree = await this.capture(path, nodeDependencies)
treeChanges(this.revision.base, tree)
if (nodeDependencies) {
const modules = join(path, 'node_modules')
if (!within(stage, modules) || (await fs.lstat(modules)).isSymbolicLink()) throw new Error('依赖层清理路径无效')
await fs.rm(modules, { recursive: true, force: true })
}
const physicalIdentity = await sealWorkspace(path)
if (physicalIdentity !== this.revision.physicalIdentity) throw new Error('执行期间长期工作区根目录被替换')
await this.assertCurrent(signal)
await fs.rename(path, this.working)
await this.publish({ ...this.revision, tree, physicalIdentity, lastRunId: this.checkout!.runId })
await fs.unlink(join(this.root, 'checkout.json'))
this.checkedOut = false
}
private async materialize(tree: WorkspaceEntry[]): Promise<string> {
await fs.mkdir(this.working)
await this.applyTree(this.working, [], tree)
return sealWorkspace(this.working)
}
async recoverCheckout(): Promise<void> {
const markerPath = join(this.root, 'checkout.json')
if (!await exists(markerPath)) {
if (await exists(join(this.root, 'mutation.json'))) {
const mutation = await workspaceRecord<{ version: number; revision: string }>(join(this.root, 'mutation.json'), 4096)
if (mutation.version !== 1 || !this.revision || mutation.revision !== this.revision.id && mutation.revision !== this.revision.previousRevision) throw new Error('长期工作区同步日志无效')
if (mutation.revision === this.revision.id) await this.rebuildCheckpoint()
else await this.verifyPhysical()
await fs.unlink(join(this.root, 'mutation.json'))
} else {
try { await this.verifyPhysical() } catch { await this.rebuildCheckpoint() }
}
this.interrupted = false; return
}
const marker = await workspaceRecord<Checkout>(markerPath, 4096)
if (marker.version !== 1 || !uuid.test(marker.runId) || !this.revision || !uuid.test(marker.revision) || !fileId.test(marker.physicalIdentity)) throw new Error('长期工作区执行日志无效')
const run = join(sandboxConfiguration().stateRoot, 'runs', marker.runId), stage = join(run, 'stage'), staged = join(stage, 'workspace')
const request = await workspaceRecord<{ id: string; workspace: string; identity: { sessionId?: string } }>(join(run, 'request.json'), 4096)
if (request.id !== marker.runId || key(request.workspace) !== key(this.policy.workspaceRoot) || request.identity?.sessionId !== this.sessionId) throw new Error('长期工作区执行日志不属于当前会话')
const status = await releaseRunLease(await verifiedBinary(), run)
if (status === 'active' || status === 'failed') throw new Error('中断的长期工作区仍有活动或未清理的原生租约')
if (marker.revision !== this.revision.id) {
if (this.revision.previousRevision !== marker.revision || this.revision.lastRunId !== marker.runId && this.revision.lastRecoveredRunId !== marker.runId || await exists(staged)) throw new Error('长期工作区执行日志与版本不一致')
await this.verifyPhysical(); await fs.unlink(markerPath); this.checkedOut = false; this.interrupted = false; return
}
if (marker.physicalIdentity !== this.revision.physicalIdentity) throw new Error('长期工作区执行日志的物理身份不匹配')
if (await exists(staged)) {
if (await exists(this.working)) throw new Error('长期工作区执行副本与保留目录同时存在,请人工核对')
await fs.rename(staged, join(this.root, 'archives', `aborted-${marker.runId}`))
} else if (await exists(this.working)) {
const actual = await this.capture(this.working)
if (JSON.stringify(actual) === JSON.stringify(this.revision.tree)) {
await fs.unlink(markerPath); this.checkedOut = false; this.interrupted = false; return
}
await fs.rename(this.working, join(this.root, 'archives', `aborted-${randomUUID()}`))
}
const physicalIdentity = await this.materialize(this.revision.tree)
await this.publish({ ...this.revision, physicalIdentity, lastRecoveredRunId: marker.runId })
await fs.unlink(markerPath); this.checkedOut = false; this.interrupted = false
}
private async rebuildCheckpoint(): Promise<void> {
if (!this.revision) throw new Error('没有可恢复的长期工作区版本')
validateTree(this.revision.tree, this.policy)
if (await exists(this.working)) await fs.rename(this.working, join(this.root, 'archives', `recovered-${randomUUID()}`))
const physicalIdentity = await this.materialize(this.revision.tree)
await this.publish({ ...this.revision, physicalIdentity })
}
async abort(): Promise<void> { if (this.checkedOut) await this.recoverCheckout() }
async discard(): Promise<void> {
if (await exists(join(this.root, 'commit-intent.json'))) throw new Error('有宿主提交结果尚未确定,请先 recover')
const markerPath = join(this.root, 'checkout.json')
if (await exists(markerPath)) {
const marker = await workspaceRecord<Checkout>(markerPath, 4096)
if (marker.version !== 1 || !uuid.test(marker.runId)) throw new Error('长期工作区执行日志无效')
const run = join(sandboxConfiguration().stateRoot, 'runs', marker.runId)
const request = await workspaceRecord<{ id: string; workspace: string; identity: { sessionId?: string } }>(join(run, 'request.json'), 4096)
if (request.id !== marker.runId || key(request.workspace) !== key(this.policy.workspaceRoot) || request.identity?.sessionId !== this.sessionId) throw new Error('长期工作区执行日志不属于当前会话')
const status = await releaseRunLease(await verifiedBinary(), run)
if (status === 'active' || status === 'failed') throw new Error('不能丢弃仍有活动原生租约的长期工作区')
const staged = join(run, 'stage', 'workspace')
if (await exists(staged)) await fs.rename(staged, join(this.root, 'archives', `discarded-run-${randomUUID()}`))
await fs.unlink(markerPath)
}
if (await exists(join(this.root, 'mutation.json'))) await fs.rename(join(this.root, 'mutation.json'), join(this.root, 'archives', `mutation-${randomUUID()}.json`))
if (await exists(this.working)) await fs.rename(this.working, join(this.root, 'archives', `discarded-${randomUUID()}`))
if (await exists(join(this.root, 'active.json'))) await fs.rename(join(this.root, 'active.json'), join(this.root, 'archives', `active-${randomUUID()}.json`))
this.revision = null; this.interrupted = false; this.checkedOut = false; this.conflicts = []
await this.initialize()
}
async fileChanges(): Promise<{ files: FileChange[]; directories: DirectoryChange[] }> {
const files: FileChange[] = [], directories: DirectoryChange[] = []
for (const change of this.view().changes) {
if (change.operation === 'mkdir' || change.operation === 'rmdir') directories.push({ path: change.path, before: change.operation === 'rmdir', after: change.operation === 'mkdir' })
else files.push({ path: change.path, beforeHash: change.beforeHash, after: change.afterHash === null ? null : await this.blob(index(this.revision!.tree).get(key(change.path))!) })
}
return { files, directories }
}
async committed(transactionId: string): Promise<void> {
if (!this.revision || !uuid.test(transactionId)) throw new Error('长期工作区提交事务身份无效')
await this.publish({ ...this.revision, base: this.revision.tree, lastTransactionId: transactionId })
}
view(): SandboxWorkspaceView {
if (!this.revision) return { mode: 'persistent', state: 'missing', sessionId: this.sessionId, changes: [] }
const stale = this.revision.policyFingerprint !== this.policy.fingerprint || this.revision.workspaceIdentity !== this.rootIdentity
return { mode: 'persistent', state: stale ? 'stale' : this.checkedOut || this.interrupted ? 'interrupted' : this.conflicts.length ? 'conflict' : 'ready', sessionId: this.sessionId,
revision: this.revision.id, policyRevision: this.revision.policyRevision,
...(stale ? {} : { files: this.revision.tree.filter(entry => entry.kind === 'file').length, bytes: this.revision.tree.reduce((sum, entry) => sum + (entry.size ?? 0), 0),
changes: treeChanges(this.revision.base, this.revision.tree), conflicts: this.conflicts }), changes: stale ? [] : treeChanges(this.revision.base, this.revision.tree),
...(this.revision.lastTransactionId ? { lastTransactionId: this.revision.lastTransactionId } : {}) }
}
async close(): Promise<void> {
if (!this.lock) return
const lock = this.lock; this.lock = undefined
await lock.close()
const path = join(this.root, 'workspace.lock'), current = await workspaceRecord<{ nonce: string }>(path, 4096)
if (current.nonce !== this.lockNonce) throw new Error('长期工作区锁归属已变化')
await fs.unlink(path)
}
}