From f8922d9e95a612330595f5251d8dd48e9b255604 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Tue, 13 Oct 2026 19:27:18 +0800 Subject: [PATCH 01/10] feat(runtime): persist trusted Write evidence before dispatch Refs #4319 Generated-by: OpenAI Codex --- ...hase4-workspace-checkpoint-design.zh-CN.md | 34 ++ packages/core/package.json | 1 + packages/core/src/file-write-evidence.ts | 203 ++++++++ packages/core/src/runtime-event.ts | 28 +- .../builtin-tools-file-worker.test.ts | 2 + .../src/__tests__/file-write-evidence.test.ts | 457 ++++++++++++++++++ .../filesystem-worker-client.test.ts | 185 +++++++ packages/runtime/src/builtin-tools.ts | 31 +- packages/runtime/src/file-write-evidence.ts | 236 +++++++++ packages/runtime/src/file-write-lock.ts | 34 ++ packages/runtime/src/filesystem-executor.ts | 128 ++++- .../runtime/src/filesystem-worker/client.ts | 104 +++- .../src/filesystem-worker/operations.ts | 46 +- .../runtime/src/filesystem-worker/protocol.ts | 35 +- packages/runtime/src/tool-runtime.ts | 89 ++-- packages/runtime/src/workspace-executor.ts | 36 ++ 16 files changed, 1578 insertions(+), 71 deletions(-) create mode 100644 packages/core/src/file-write-evidence.ts create mode 100644 packages/runtime/src/__tests__/file-write-evidence.test.ts create mode 100644 packages/runtime/src/file-write-evidence.ts diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index 21b00c7b4b5..1de9af264f5 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -482,6 +482,40 @@ PR C 沿用 PR A 的 durable vocabulary:`matches_expected_state` 可 finalize `matches_prior_state`、`diverged`、`unreadable` 均提交 terminal parked decision。UI 直接展示 durable reason code(如 `reconcile_matches_prior_state`),不维护第二套展示层状态名。 +#### PR C 的首个实现切片:Write 的 T1 evidence + +单文件 Write 的 evidence 由拥有文件权限的 executor 产生,位于 +`RuntimeEvent.actions.toolDispatch.fileWriteEvidence`。它随 call、dispatch 和 journal +通过现有 `commitToolPrepared` 事务原子提交;不新增恢复数据库或独立持久化回调。 + +固定 contract 为 `builtin.Write.utf8` version 1,transform 为 +`overwrite_utf8_v1`。记录包含 canonical cwd 的目录身份、canonical target 与父目录身份、 +before 的文件身份/字节数/SHA-256(或 missing)、expected-after 的字节数/SHA-256, +以及与普通 Write 共用构造逻辑的 worker result。外层 digest 还绑定 session、invocation、 +run、turn、operation、provider call、dispatch event 和 canonical args hash。 +decoder 拒绝未知 contract、digest 改写与跨操作替换。 + +执行顺序为:获取现有文件写锁 → 非变更 prepare → T1 → guarded write → T2。 +worker 协议 v12 增加 `prepare_write`,继续使用一次请求一个进程的生命周期。 +prepare 不创建 missing target,也不跨 T1 持有 worker 文件句柄。filesystem client 在宿主侧 +捕获目录身份,并在 dispatch 前及 worker 返回后复核。仅 Linux mount namespace 通过 request +传递宿主身份,其临时父目录 inode 不能作为宿主身份;missing target 的 pinned mount source +必须与证据中的父目录身份一致。原生 worker 直接复核实际目录。worker 在同一个已验证句柄上检查 before 内容并写入。missing target 只在 T1 后以 exclusive create +创建。取消、prepare/T1 失败和执行结束释放同一把写锁;prepared execution 只能消费一次。 + +观察边界为可读取的 regular UTF-8 文件,before/after 各最多 1 MiB,result JSON 最多 +256 KiB;符号链接别名沿用既有 canonical target 规则,最终打开目标时不跟随新链接。 +不支持的观察(包括没有该能力的外部 executor)不记录 evidence,保留原 Write 执行路径。 +本地 backend 也保留其 image-path 不生成文本 diff 的规则。 + +这个切片仍固定 `never_auto_retry`,没有注册可执行 reconciler,也不调用 +`commitToolRecoveryBundle`。重启后可以读取 T1 evidence,但不能据此宣布恢复成功或自动重写。 +进程内锁不排斥 Bash 或外部程序;复核与实际写入之间的外部并发窗口仍然存在, +因此该 contract 不是通用 filesystem transaction 或跨进程 CAS。 + +测试入口:`packages/runtime/src/__tests__/file-write-evidence.test.ts`,覆盖 T1 内部回滚、 +T1/T2 间断点重开、外部改写、身份替换、取消/重复执行、证据替换及真实一次性 worker。 + ### PR D — Host owner lifecycle 不变量: diff --git a/packages/core/package.json b/packages/core/package.json index 85bda2bad27..fb4a5992f4f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,6 +18,7 @@ "./runtime-logical-execution": "./dist/runtime-logical-execution.js", "./runtime-event": "./dist/runtime-event.js", "./tool-args-identity": "./dist/tool-args-identity.js", + "./file-write-evidence": "./dist/file-write-evidence.js", "./tool-ledger-scanner": "./dist/tool-ledger-scanner.js", "./tool-recovery-fact": "./dist/tool-recovery-fact.js", "./tool-recovery-bundle": "./dist/tool-recovery-bundle.js", diff --git a/packages/core/src/file-write-evidence.ts b/packages/core/src/file-write-evidence.ts new file mode 100644 index 00000000000..def63295433 --- /dev/null +++ b/packages/core/src/file-write-evidence.ts @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { stableJsonStringify } from './tool-args-identity.js'; + +/** Observation contract only. Registration does not authorize reconciliation. */ +export const FILE_WRITE_EVIDENCE_CONTRACT = Object.freeze({ + id: 'builtin.Write.utf8', + version: 1, + algorithm: 'overwrite_utf8_v1', + maxFileBytes: 1_048_576, + maxResultBytes: 262_144, + pathPolicy: 'canonical_target_no_follow_v1', +} as const); + +export interface FileIdentity { + dev: string; + ino: string; +} + +/** Host identities; a sandbox may synthesize different directory inodes. */ +export interface FileWriteDirectories { + workspace: FileIdentity; + parent: FileIdentity; +} + +export interface FileWriteObservation { + contract: typeof FILE_WRITE_EVIDENCE_CONTRACT; + workspace: { path: string; identity: FileIdentity }; + target: { path: string; parentIdentity: FileIdentity }; + before: + | { kind: 'missing' } + | { kind: 'file'; identity: FileIdentity; bytes: number; digest: string }; + after: { bytes: number; digest: string }; + result: { kind: 'write'; ok: true; path: string; bytes: number; diff?: string }; +} + +export interface FileWriteEvidenceBinding { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; + operationId: string; + dispatchEventId: string; + providerToolCallId: string; + canonicalArgsHash: string; +} + +export interface FileWriteEvidence { + observation: FileWriteObservation; + binding: FileWriteEvidenceBinding; + digest: string; +} + +export function fileEvidenceDigest(value: unknown): string { + return `sha256:${createHash('sha256').update(stableJsonStringify(value)).digest('hex')}`; +} + +export function bindFileWriteEvidence( + observation: FileWriteObservation, + binding: FileWriteEvidenceBinding, +): FileWriteEvidence { + const body = structuredClone({ observation, binding }); + if (!isFileWriteObservation(body.observation) || !isBinding(body.binding)) { + throw new Error('Invalid file Write evidence'); + } + return { ...body, digest: fileEvidenceDigest(body) }; +} + +export function isFileWriteEvidence(value: unknown): value is FileWriteEvidence { + try { + if (!record(value, ['observation', 'binding', 'digest'])) return false; + return ( + isFileWriteObservation(value.observation) && + isBinding(value.binding) && + value.digest === + fileEvidenceDigest({ observation: value.observation, binding: value.binding }) + ); + } catch { + return false; + } +} + +export function isFileWriteObservation(value: unknown): value is FileWriteObservation { + try { + return validateObservation(value); + } catch { + return false; + } +} + +function validateObservation(value: unknown): value is FileWriteObservation { + if (!record(value, ['contract', 'workspace', 'target', 'before', 'after', 'result'])) + return false; + const { contract, workspace, target, before, after, result } = value; + return ( + record(contract, Object.keys(FILE_WRITE_EVIDENCE_CONTRACT)) && + stableJsonStringify(contract) === stableJsonStringify(FILE_WRITE_EVIDENCE_CONTRACT) && + record(workspace, ['path', 'identity']) && + path(workspace.path) && + identity(workspace.identity) && + record(target, ['path', 'parentIdentity']) && + path(target.path) && + identity(target.parentIdentity) && + ((record(before, ['kind']) && before.kind === 'missing') || + (record(before, ['kind', 'identity', 'bytes', 'digest']) && + before.kind === 'file' && + identity(before.identity) && + bytes(before.bytes) && + digest(before.digest))) && + record(after, ['bytes', 'digest']) && + bytes(after.bytes) && + digest(after.digest) && + record(result, ['kind', 'ok', 'path', 'bytes'], ['diff']) && + result.kind === 'write' && + result.ok === true && + result.path === target.path && + result.bytes === after.bytes && + (result.diff === undefined || typeof result.diff === 'string') && + Buffer.byteLength(stableJsonStringify(result), 'utf8') <= + FILE_WRITE_EVIDENCE_CONTRACT.maxResultBytes + ); +} + +function isBinding(value: unknown): value is FileWriteEvidenceBinding { + return ( + record(value, [ + 'sessionId', + 'invocationId', + 'runId', + 'turnId', + 'operationId', + 'dispatchEventId', + 'providerToolCallId', + 'canonicalArgsHash', + ]) && + Object.values(value).every((item) => typeof item === 'string' && item.length > 0) && + digest(value.canonicalArgsHash) + ); +} + +function record( + value: unknown, + required: string[], + optional: string[] = [], +): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every((key) => required.includes(key) || optional.includes(key)) + ); +} + +function identity(value: unknown): value is FileIdentity { + return ( + record(value, ['dev', 'ino']) && + typeof value.dev === 'string' && + /^\d+$/.test(value.dev) && + typeof value.ino === 'string' && + /^\d+$/.test(value.ino) + ); +} + +function digest(value: unknown): boolean { + return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); +} + +function path(value: unknown): boolean { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 4096 && + !value.includes('\0') && + (value.startsWith('/') || value.startsWith('\\\\') || /^[a-z]:[\\/]/i.test(value)) + ); +} + +function bytes(value: unknown): boolean { + return ( + Number.isSafeInteger(value) && + (value as number) >= 0 && + (value as number) <= FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes + ); +} diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 3e5ade5b7b0..5df9720bd8f 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -34,6 +34,7 @@ */ import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; +import { isFileWriteEvidence, type FileWriteEvidence } from './file-write-evidence.js'; import { isToolCallOutcome } from './tool-result-status.js'; import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js'; @@ -445,6 +446,8 @@ export interface RuntimeEventToolDispatch { recoveryMode: ToolRecoveryMode; /** T1-frozen managed workspace mutation identity. */ managedMutation?: RuntimeEventManagedWorkspaceMutationV2; + /** Trusted pre-dispatch observation; does not by itself authorize replay. */ + fileWriteEvidence?: FileWriteEvidence; } /** @@ -913,7 +916,7 @@ const RUNTIME_TOOL_DISPATCH_SHAPE = defineObjectShape( 'canonicalArgsHash', 'recoveryMode', ], - ['managedMutation', 'resultProjectionVersion'], + ['managedMutation', 'resultProjectionVersion', 'fileWriteEvidence'], ); const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = defineObjectShape()( @@ -1032,6 +1035,26 @@ export function decodeRuntimeEvent(value: unknown): RuntimeEvent { throw new Error('Invalid RuntimeEvent schema'); } runtimeHandoffPause(value as unknown as RuntimeEvent); + const event = value as unknown as RuntimeEvent; + const dispatch = event.actions?.toolDispatch; + if (dispatch?.fileWriteEvidence) { + const binding = dispatch.fileWriteEvidence.binding; + if ( + dispatch.toolName !== 'Write' || + dispatch.managedMutation !== undefined || + dispatch.recoveryMode !== 'never_auto_retry' || + binding.sessionId !== event.sessionId || + binding.invocationId !== event.invocationId || + binding.runId !== event.runId || + binding.turnId !== event.turnId || + binding.dispatchEventId !== event.id || + binding.operationId !== dispatch.operationId || + binding.providerToolCallId !== dispatch.providerToolCallId || + binding.canonicalArgsHash !== dispatch.canonicalArgsHash + ) { + throw new Error('File Write evidence does not match its dispatch'); + } + } if (isRecord(value.content) && value.content.kind === 'text') { return { ...value, @@ -1438,7 +1461,8 @@ function isRuntimeToolDispatch(value: unknown): value is RuntimeEventToolDispatc value.recoveryMode === 'outcome_unknown' || value.recoveryMode === 'never_auto_retry') && (value.managedMutation === undefined || - isRuntimeManagedWorkspaceMutation(value.managedMutation)) + isRuntimeManagedWorkspaceMutation(value.managedMutation)) && + (value.fileWriteEvidence === undefined || isFileWriteEvidence(value.fileWriteEvidence)) ); } diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts index 23ffb578401..b1f2f44e380 100644 --- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts @@ -185,6 +185,8 @@ describe('builtin file tools use the sandboxed worker', () => { execute: async (input) => { calls.push(input); switch (input.operation.kind) { + case 'prepare_write': + return { kind: 'prepare_write' }; case 'read': return { kind: 'read', diff --git a/packages/runtime/src/__tests__/file-write-evidence.test.ts b/packages/runtime/src/__tests__/file-write-evidence.test.ts new file mode 100644 index 00000000000..1819959b5f2 --- /dev/null +++ b/packages/runtime/src/__tests__/file-write-evidence.test.ts @@ -0,0 +1,457 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath, rm, writeFile, rename } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type TestContext } from 'node:test'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; +import { + isFileWriteEvidence, + fileEvidenceDigest, + FILE_WRITE_EVIDENCE_CONTRACT, +} from '@maka/core/file-write-evidence'; +import { decodeRuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeCommitSink, ToolPreparedCommit } from '../runtime-commit-sink.js'; +import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; +import { buildBuiltinTools } from '../builtin-tools.js'; +import { createLocalWorkspaceExecutor } from '../workspace-executor.js'; +import { createBoundaryFilesystemExecutor } from '../filesystem-executor.js'; +import { executePreparedFileWrite, prepareFileWrite } from '../file-write-evidence.js'; +import { runFilesystemWorkerProcess } from '../filesystem-worker/process-runner.js'; +import { + FILESYSTEM_WORKER_PROTOCOL_VERSION, + parseFilesystemWorkerResponse, +} from '../filesystem-worker/protocol.js'; + +async function fixture(t: TestContext) { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-write-evidence-'))); + const path = join(cwd, 'notes.txt'); + const dbPath = join(cwd, 'runtime.sqlite'); + const store = createSqliteRuntimeStore(dbPath); + t.after(async () => { + store.close(); + await rm(cwd, { recursive: true, force: true }); + }); + const executor = createLocalWorkspaceExecutor(); + const filesystem = createBoundaryFilesystemExecutor({ workspace: executor }); + const tool = buildBuiltinTools({ executor }).find((candidate) => candidate.name === 'Write')!; + const call = (signal?: AbortSignal) => ({ + operation: { kind: 'write' as const, path, content: 'after\n' }, + cwd, + executionBoundary: { kind: 'bypass' as const, revision: 0 }, + abortSignal: signal, + }); + function execute(sink: RuntimeCommitSink = store, abortSignal = new AbortController().signal) { + let id = 0; + const runtime = createTestToolRuntime({ + sessionId: 'session-1', + runId: 'run-1', + invocationId: 'invocation-1', + header: { + id: 'session-1', + cwd, + workspaceRoot: cwd, + createdAt: 1, + name: 'test', + titleIsManual: false, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'bypass', + schemaVersion: 1, + }, + connection: { slug: 'test', providerType: 'openai', defaultModel: 'test' }, + modelId: 'test', + newId: () => `event-${++id}`, + now: () => ++id, + getPermissionPauseTarget: () => null, + readExecutionBoundary: async () => ({ kind: 'bypass', revision: 0 }), + runtimeCommitSink: sink, + }); + return runtime.settleToolCall({ + tool, + turnId: 'turn-1', + toolCallId: 'provider-call-1', + input: { path, content: 'after\n' }, + abortSignal, + eventSink: { push: () => {}, pushAndWaitUntilConsumed: async () => {} }, + }); + } + return { cwd, path, dbPath, store, filesystem, call, execute }; +} + +for (const existing of [false, true]) { + test(`T1 failure leaves ${existing ? 'existing' : 'missing'} file untouched and releases the lock`, async (t) => { + const f = await fixture(t); + if (existing) await writeFile(f.path, 'before\n'); + let prepared: ToolPreparedCommit | undefined; + await assert.rejects( + f.execute({ + commitToolPrepared: async (input) => { + prepared = input; + throw new Error('T1 unavailable'); + }, + commitToolOutcome: async () => { + throw new Error('must not reach T2'); + }, + }), + /T1 unavailable/, + ); + assert.ok( + isFileWriteEvidence(prepared?.dispatchRuntimeEvent.actions?.toolDispatch?.fileWriteEvidence), + ); + if (existing) assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + else await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + assert.deepEqual(await f.store.readRuntimeEvents('session-1', 'run-1'), []); + const retry = await f.filesystem.prepareWrite(f.call()); + retry.cancel(); + }); +} + +test('ordinary Write persists bound evidence and keeps the production diff', async (t) => { + const f = await fixture(t); + await writeFile(f.path, 'before\n'); + const expected = await prepareFileWrite(f.cwd, f.path, 'after\n'); + assert.ok(expected); + const settled = await f.execute({ + commitToolPrepared: async (input) => { + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + return f.store.commitToolPrepared(input); + }, + commitToolOutcome: (input) => f.store.commitToolOutcome(input), + }); + assert.deepEqual(settled.result, { + kind: 'file_diff', + paths: [f.path], + diff: expected.result.diff, + }); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); + const events = await f.store.readRuntimeEvents('session-1', 'run-1'); + const dispatch = events.find((event) => event.actions?.toolDispatch)!; + const evidence = dispatch.actions!.toolDispatch!.fileWriteEvidence!; + assert.ok(isFileWriteEvidence(evidence)); + assert.equal(evidence.binding.dispatchEventId, dispatch.id); + assert.equal( + evidence.binding.canonicalArgsHash, + dispatch.actions!.toolDispatch!.canonicalArgsHash, + ); + assert.equal(dispatch.actions!.toolDispatch!.recoveryMode, 'never_auto_retry'); + assert.ok(events.some((event) => event.content?.kind === 'function_response')); +}); + +for (const point of ['after_runtime_event_insert', 'after_journal_event_insert'] as const) { + test(`SQLite rolls evidence and T1 back together at ${point}`, async (t) => { + const f = await fixture(t); + const dbPath = join(f.cwd, 'fault.sqlite'); + const broken = createSqliteRuntimeStore(dbPath, { + failpoint: (reached) => { + if (reached === point) throw new Error(`T1 failpoint ${point}`); + }, + }); + try { + await assert.rejects(f.execute(broken), /T1 failpoint/); + assert.deepEqual(await broken.readRuntimeEvents('session-1', 'run-1'), []); + await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + } finally { + broken.close(); + } + const reopened = createSqliteRuntimeStore(dbPath); + try { + assert.deepEqual(await reopened.readRuntimeEvents('session-1', 'run-1'), []); + } finally { + reopened.close(); + } + const next = await f.filesystem.prepareWrite(f.call()); + next.cancel(); + }); +} + +for (const point of ['after_t1', 'before_t2'] as const) { + test(`reopen retains evidence after a simulated crash ${point}`, async (t) => { + const f = await fixture(t); + await assert.rejects( + f.execute({ + commitToolPrepared: async (input) => { + const committed = await f.store.commitToolPrepared(input); + if (point === 'after_t1') throw new Error('simulated crash after T1'); + return committed; + }, + commitToolOutcome: async () => { + throw new Error('simulated crash before T2'); + }, + }), + /simulated crash/, + ); + if (point === 'after_t1') await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + else assert.equal(await readFile(f.path, 'utf8'), 'after\n'); + f.store.close(); + const reopened = createSqliteRuntimeStore(f.dbPath); + try { + await reopened.rebuildToolProjectionsFromRuntimeEvents(); + const events = await reopened.readRuntimeEvents('session-1', 'run-1'); + const dispatch = events.find((event) => event.actions?.toolDispatch)!; + assert.ok(isFileWriteEvidence(dispatch.actions!.toolDispatch!.fileWriteEvidence)); + assert.equal( + (await reopened.readToolOperation(dispatch.actions!.toolDispatch!.operationId)) + ?.currentState, + 'prepared', + ); + assert.ok(!events.some((event) => event.content?.kind === 'function_response')); + } finally { + reopened.close(); + } + const next = await f.filesystem.prepareWrite(f.call()); + next.cancel(); + }); +} + +test('an in-place external edit after T1 is rejected before truncation', async (t) => { + const f = await fixture(t); + await writeFile(f.path, 'before\n'); + const result = await f.execute({ + commitToolPrepared: async (input) => { + const committed = await f.store.commitToolPrepared(input); + await writeFile(f.path, 'external edit\n'); + return committed; + }, + commitToolOutcome: (input) => f.store.commitToolOutcome(input), + }); + assert.match(JSON.stringify(result), /prepared Write target changed/); + assert.equal(await readFile(f.path, 'utf8'), 'external edit\n'); +}); + +test('prepared writes reject inode replacement and a file appearing at a missing target', async (t) => { + const f = await fixture(t); + const missing = await f.filesystem.prepareWrite(f.call()); + await writeFile(f.path, 'external create'); + await assert.rejects(missing.execute(), /appeared|created/); + assert.equal(await readFile(f.path, 'utf8'), 'external create'); + const existing = await f.filesystem.prepareWrite(f.call()); + await rename(f.path, join(f.cwd, 'old.txt')); + await writeFile(f.path, 'replacement'); + await assert.rejects(existing.execute(), /changed/); + assert.equal(await readFile(f.path, 'utf8'), 'replacement'); + assert.equal(await readFile(join(f.cwd, 'old.txt'), 'utf8'), 'external create'); +}); + +test('cancellation and duplicate execution cannot leak a prepared lock or write twice', async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + const first = await f.filesystem.prepareWrite(f.call(controller.signal)); + const waitingController = new AbortController(); + const waiting = f.filesystem.prepareWrite(f.call(waitingController.signal)); + waitingController.abort(new Error('cancel waiting')); + await assert.rejects(waiting, /cancel waiting/); + controller.abort(new Error('cancel prepared')); + await assert.rejects(first.execute(), /already settled/); + first.cancel(); + const next = await f.filesystem.prepareWrite(f.call()); + await next.execute(); + await writeFile(f.path, 'later edit'); + await assert.rejects(next.execute(), /already settled/); + next.cancel(); + assert.equal(await readFile(f.path, 'utf8'), 'later edit'); +}); + +test('observation and caller argument mutation cannot change the prepared execution', async (t) => { + const f = await fixture(t); + const call = f.call(); + const prepared = await f.filesystem.prepareWrite(call); + call.operation.content = 'wrong'; + prepared.observation!.after.digest = `sha256:${'0'.repeat(64)}`; + await prepared.execute(); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); +}); + +test('invalid UTF-8, oversized content, and unsupported executor observations stay conservative', async (t) => { + const f = await fixture(t); + await writeFile(f.path, Buffer.from([0xff, 0xfe])); + assert.equal(await prepareFileWrite(f.cwd, f.path, 'after'), undefined); + assert.equal( + await prepareFileWrite( + f.cwd, + f.path, + 'a'.repeat(FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes + 1), + ), + undefined, + ); + const prepared = await f.filesystem.prepareWrite(f.call()); + assert.equal(prepared.observation, undefined); + await prepared.execute(); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); + const unsupported = createBoundaryFilesystemExecutor({ + workspace: Object.assign(createLocalWorkspaceExecutor(), { + prepareWrite: undefined, + executePreparedWrite: undefined, + }), + }); + const fallback = await unsupported.prepareWrite(f.call()); + assert.equal(fallback.observation, undefined); + await fallback.execute(); +}); + +test('evidence tampering, contract drift and cross-operation substitution are rejected', async (t) => { + const f = await fixture(t); + let prepared!: ToolPreparedCommit; + await assert.rejects( + f.execute({ + commitToolPrepared: async (input) => { + prepared = structuredClone(input); + throw new Error('capture'); + }, + commitToolOutcome: async () => { + throw new Error('unexpected T2'); + }, + }), + /capture/, + ); + for (const mutation of ['digest', 'version', 'binding'] as const) { + const bad = structuredClone(prepared); + const evidence = bad.dispatchRuntimeEvent.actions!.toolDispatch!.fileWriteEvidence!; + if (mutation === 'digest') evidence.observation.after.bytes++; + if (mutation === 'version') Object.assign(evidence.observation.contract, { version: 2 }); + if (mutation === 'binding') { + evidence.binding.operationId = 'different-operation'; + evidence.digest = fileEvidenceDigest({ + observation: evidence.observation, + binding: evidence.binding, + }); + } + assert.throws(() => decodeRuntimeEvent(bad.dispatchRuntimeEvent)); + await assert.rejects(f.store.commitToolPrepared(bad)); + assert.deepEqual(await f.store.readRuntimeEvents('session-1', 'run-1'), []); + } +}); + +test('real one-shot worker prepares without creating and consumes the same observation on execution', async (t) => { + const f = await fixture(t); + const request = { + version: FILESYSTEM_WORKER_PROTOCOL_VERSION, + // Deliberately differ from this process's directory inodes, as a Linux + // worker's synthetic ancestors differ from the trusted host observation. + hostDirectories: { workspace: { dev: '101', ino: '102' }, parent: { dev: '103', ino: '104' } }, + requestId: 'worker-prepare', + operation: { kind: 'prepare_write', cwd: f.cwd, path: f.path, content: 'after\n' }, + operationBoundary: { + filesystem: { entries: [{ path: f.path, access: 'write', scope: 'exact' }] }, + }, + expectedTarget: { + enforcementPath: f.path, + access: 'write', + scope: 'exact', + targetType: 'missing', + identity: 'missing', + }, + }; + const run = async (body: unknown) => { + const result = await runFilesystemWorkerProcess({ + argv: [ + process.execPath, + fileURLToPath(new URL('../filesystem-worker/worker-entry.js', import.meta.url)), + ], + cwd: f.cwd, + env: process.env, + stdin: JSON.stringify(body), + }); + assert.equal(result.exitCode, 0, result.stderrTail); + return parseFilesystemWorkerResponse(JSON.parse(result.stdout)); + }; + const prepared = await run(request); + assert.ok(prepared.ok && prepared.result.kind === 'prepare_write' && prepared.result.observation); + await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + const observation = prepared.result.observation; + assert.deepEqual(observation.workspace.identity, request.hostDirectories.workspace); + assert.deepEqual(observation.target.parentIdentity, request.hostDirectories.parent); + const rejected = await run({ + ...request, + hostDirectories: { ...request.hostDirectories, parent: { dev: '103', ino: '999' } }, + operation: { ...request.operation, kind: 'write', observation }, + }); + assert.ok(!rejected.ok && rejected.error.code === 'path_changed'); + await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + const executed = await run({ + ...request, + requestId: 'worker-write', + operation: { ...request.operation, kind: 'write', observation }, + }); + assert.ok(executed.ok); + assert.deepEqual(executed.result, observation.result); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); +}); + +test('a failed preparation worker releases the lock without dispatching a write', async (t) => { + const f = await fixture(t); + const operations: string[] = []; + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + worker: { + execute: async ({ operation }) => { + operations.push(operation.kind); + throw new Error('worker died'); + }, + }, + }); + const call = f.call(); + // The embedding default routes to its configured worker. + const { executionBoundary: _boundary, ...workerCall } = call; + await assert.rejects(filesystem.prepareWrite(workerCall), /worker died/); + assert.deepEqual(operations, ['prepare_write']); + await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + const next = await f.filesystem.prepareWrite(f.call()); + next.cancel(); +}); + +test('prepared and ordinary local Write retain identical result shapes', async (t) => { + const f = await fixture(t); + for (const previous of ['', 'before', 'new', 'unknown']) { + await writeFile(f.path, previous); + const prepared = await f.filesystem.prepareWrite(f.call()); + const result = await prepared.execute(); + await writeFile(f.path, previous); + assert.deepEqual(await f.filesystem.execute(f.call()), result); + } + const call = f.call(); + call.operation.path = join(f.cwd, 'image.png'); + await writeFile(call.operation.path, 'text with an image extension'); + const prepared = await f.filesystem.prepareWrite(call); + assert.equal(prepared.observation, undefined); + const result = await prepared.execute(); + assert.equal(result.kind === 'write' && result.diff, undefined); +}); + +test('prepared primitive refuses content substitution before creating a file', async (t) => { + const f = await fixture(t); + const observation = await prepareFileWrite(f.cwd, f.path, 'after\n'); + assert.ok(observation); + await assert.rejects( + executePreparedFileWrite(f.cwd, f.path, 'substitution', observation), + /changed/, + ); + await assert.rejects(readFile(f.path), { code: 'ENOENT' }); +}); diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index 82471edb10f..32c22643400 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -25,6 +25,7 @@ import { mkdir, readFile, realpath, + rename, rm, symlink, writeFile, @@ -35,6 +36,8 @@ import { afterEach, describe, test } from 'node:test'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { prepareFileWrite } from '../file-write-evidence.js'; +import { executeFilesystemWorkerRequest } from '../filesystem-worker/operations.js'; import { canReadPath, canWritePath, @@ -79,6 +82,186 @@ test('Read image payloads fit within the filesystem worker response limit', () = }); describe('filesystem worker client permission snapshots', () => { + test('Write preparation uses the same write grant and missing-target identity as execution', async () => { + const workspace = await temporaryDirectory('maka-worker-prepare-grant-'); + const { client, requests } = fakeClient(); + for (const kind of ['prepare_write', 'write'] as const) { + await client.execute({ + operation: { kind, path: 'notes.txt', content: 'after' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'missing', + }); + } + assert.equal(requests[0]!.operation.kind, 'prepare_write'); + assert.deepEqual(requests[0]!.operationBoundary, requests[1]!.operationBoundary); + assert.deepEqual(requests[0]!.expectedTarget, requests[1]!.expectedTarget); + assert.equal(requests[0]!.expectedTarget.access, 'write'); + // Native namespaces must recheck their actual directories inside the worker. + assert.equal(requests[0]!.hostDirectories, undefined); + }); + + test('a prepared Write rejects substituted host directory identity before worker launch', async () => { + const workspace = await temporaryDirectory('maka-worker-prepare-host-'); + const path = join(workspace, 'notes.txt'); + const observation = await prepareFileWrite(workspace, path, 'after'); + assert.ok(observation); + observation.workspace.identity.ino += '1'; + const { client, requests } = fakeClient(); + await assert.rejects( + client.execute({ + operation: { kind: 'write', path, content: 'after', observation }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'missing', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && error.reason === 'path_changed', + ); + assert.deepEqual(requests, []); + }); + + for (const swapAt of ['launch', 'worker', 'after-write'] as const) { + test(`a prepared Write rejects a parent replacement during ${swapAt}`, async () => { + const cwd = await temporaryDirectory('maka-worker-parent-race-'); + const parent = join(cwd, 'parent'); + const previous = join(cwd, 'previous'); + const path = join(parent, 'target.txt'); + await mkdir(parent); + const observation = await prepareFileWrite(cwd, path, 'after'); + assert.ok(observation); + const swap = async () => { + await rename(parent, previous); + await mkdir(parent); + }; + let dispatched = false; + const client = new FilesystemWorkerClient({ + platform: 'darwin', + sandboxManager: new SandboxManager([new MacosSeatbeltBackend()]), + getLaunchSpec: async () => { + if (swapAt === 'launch') await swap(); + return { + ok: true, + spec: { + program: process.execPath, + args: [], + env: {}, + runtimeReadableRoots: [], + executableRoots: [process.execPath], + }, + }; + }, + runProcess: async (input) => { + dispatched = true; + const request = FilesystemWorkerRequestSchema.parse(JSON.parse(input.stdin)); + assert.equal(request.hostDirectories, undefined); + if (swapAt === 'worker') await swap(); + const response = await executeFilesystemWorkerRequest(request); + if (swapAt === 'after-write') { + assert.ok(response.ok); + await swap(); + } + return { + exitCode: 0, + stdout: JSON.stringify(response), + stderrTail: '', + timedOut: false, + aborted: false, + responseOverflow: false, + dispatched: true, + }; + }, + }); + await assert.rejects( + client.execute({ + operation: { kind: 'write', path, content: 'after', observation }, + cwd, + mode: 'ask', + expectedIdentity: 'missing', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && + error.reason === (swapAt === 'after-write' ? 'outcome_unknown' : 'path_changed'), + ); + assert.equal(dispatched, swapAt !== 'launch'); + await assert.rejects(readFile(path), { code: 'ENOENT' }); + if (swapAt === 'after-write') { + assert.equal(await readFile(join(previous, 'target.txt'), 'utf8'), 'after'); + } else { + await assert.rejects(readFile(join(previous, 'target.txt')), { code: 'ENOENT' }); + } + }); + } + + test('a prepared Write rejects a replacement Linux mount source', { + skip: process.platform !== 'linux', + }, async () => { + const cwd = await temporaryDirectory('maka-worker-mount-race-'); + const parent = join(cwd, 'parent'); + await mkdir(parent); + const path = join(parent, 'target.txt'); + const observation = await prepareFileWrite(cwd, path, 'after'); + assert.ok(observation); + let transformed = false; + const manager = new SandboxManager([]); + const transform = manager.transform.bind(manager); + const client = new FilesystemWorkerClient({ + platform: 'linux', + sandboxManager: Object.assign(manager, { + transform(request: SandboxTransformRequest): SandboxTransformResult { + transformed = true; + return transform(request); + }, + }), + getLaunchSpec: async () => { + await rename(parent, join(cwd, 'previous')); + await mkdir(parent); + return { + ok: true, + spec: { + program: process.execPath, + args: [], + env: {}, + runtimeReadableRoots: [], + executableRoots: [process.execPath], + }, + }; + }, + runProcess: async () => { + throw new Error('must not dispatch'); + }, + }); + await assert.rejects( + client.execute({ + operation: { kind: 'write', path, content: 'after', observation }, + cwd, + mode: 'ask', + expectedIdentity: 'missing', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && + error.reason === 'path_changed' && + error.message.includes('before pinning'), + ); + assert.equal(transformed, false); + await assert.rejects(readFile(path), { code: 'ENOENT' }); + }); + + test('a read-only profile cannot prepare a Write', async () => { + const workspace = await temporaryDirectory('maka-worker-prepare-denied-'); + const { client, requests } = fakeClient(); + await assert.rejects( + client.execute({ + operation: { kind: 'prepare_write', path: 'notes.txt', content: 'after' }, + cwd: workspace, + permissionProfile: createReadOnlyPermissionProfile(), + expectedIdentity: 'missing', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && error.reason === 'path_denied', + ); + assert.deepEqual(requests, []); + }); test('authorizes delete against the symlink entry', async () => { const workspace = await temporaryDirectory('maka-worker-client-delete-link-'); const target = join(workspace, 'target.txt'); @@ -713,6 +896,8 @@ function fakeClient( function fakeResult(request: FilesystemWorkerRequest): FilesystemWorkerResult { switch (request.operation.kind) { + case 'prepare_write': + return { kind: 'prepare_write' }; case 'read': return request.operation.path.endsWith('.png') ? { kind: 'read_image', base64: 'iVBORw==', mimeType: 'image/png' } diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index df4d176468c..e286ae99455 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -81,7 +81,7 @@ import { // tool-runtime.ts is the single source of truth for the tool shape; this // re-export only keeps back-compat for callers that imported from // builtin-tools directly. -import type { MakaTool, MakaToolContext } from './tool-runtime.js'; +import type { MakaTool, MakaToolContext, MakaToolPreparationContext } from './tool-runtime.js'; export type { MakaTool, MakaToolContext }; import { profileRequiresSandbox, type SandboxManager } from './sandbox/sandbox-manager.js'; import { SandboxCommandError } from './sandbox/errors.js'; @@ -430,16 +430,23 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT content: z.string().describe('The complete text to write to the file.'), }), executionFacts, + prepareExecution: async ({ path, content }, ctx) => { + const prepared = await filesystem.prepareWrite({ + operation: { kind: 'write', path, content }, + ...filesystemCall(ctx), + }); + return { + ...(prepared.observation ? { fileWriteObservation: prepared.observation } : {}), + cancel: () => prepared.cancel(), + execute: async () => writeToolOutput(await prepared.execute()), + }; + }, impl: async ({ path, content }, ctx) => { const result = await filesystem.execute({ operation: { kind: 'write', path, content }, ...filesystemCall(ctx), }); - if (result.kind !== 'write') - throw internalFilesystemWriteFailure('Write', 'the file was written'); - if (result.diff !== undefined) - return { kind: 'file_diff' as const, paths: [result.path], diff: result.diff }; - return { kind: 'file_write' as const, path: result.path, bytes: result.bytes }; + return writeToolOutput(result); }, toModelOutput: ({ output }) => fileWriteToolResultToModelOutput('Write', output), }, @@ -611,7 +618,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT /** The per-call context every file tool hands to the filesystem authority. */ function filesystemCall( - ctx: MakaToolContext, + ctx: MakaToolPreparationContext, ): Pick { return { cwd: ctx.cwd, @@ -621,6 +628,16 @@ function filesystemCall( }; } +function writeToolOutput( + result: Awaited['execute']>>, +) { + if (result.kind !== 'write') + throw internalFilesystemWriteFailure('Write', 'the file was written'); + if (result.diff !== undefined) + return { kind: 'file_diff' as const, paths: [result.path], diff: result.diff }; + return { kind: 'file_write' as const, path: result.path, bytes: result.bytes }; +} + interface ExecutorBashSandboxOptions { permissionProfile?: PermissionProfile; sandboxManager?: SandboxManager; diff --git a/packages/runtime/src/file-write-evidence.ts b/packages/runtime/src/file-write-evidence.ts new file mode 100644 index 00000000000..95dfdc48513 --- /dev/null +++ b/packages/runtime/src/file-write-evidence.ts @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, open, type FileHandle } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { + FILE_WRITE_EVIDENCE_CONTRACT, + isFileWriteObservation, + type FileIdentity, + type FileWriteDirectories, + type FileWriteObservation, +} from '@maka/core/file-write-evidence'; +import { createUnifiedDiff } from './unified-diff.js'; +import { + hostVisibilityAfterWrite, + openStableTarget, + StableWriteFailure, + writeThroughHandle, +} from './file-stable-write.js'; + +function identity(metadata: { dev: bigint; ino: bigint }): FileIdentity { + return { dev: String(metadata.dev), ino: String(metadata.ino) }; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function contentDigest(content: Uint8Array): string { + return `sha256:${createHash('sha256').update(content).digest('hex')}`; +} + +/** Shared production Write result, including the existing prior-content convention. */ +export function buildFileWriteResult( + path: string, + content: string, + previous: string, +): FileWriteObservation['result'] { + const diff = + previous === 'unknown' + ? undefined + : createUnifiedDiff(path, previous === 'new' ? undefined : previous, content); + return { + kind: 'write', + ok: true, + path, + bytes: Buffer.byteLength(content, 'utf8'), + ...(diff === undefined ? {} : { diff }), + }; +} + +async function directoryIdentity(path: string): Promise { + const metadata = await lstat(path, { bigint: true }); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) + throw new Error('Not a stable directory'); + return identity(metadata); +} + +/** Bounded even if a concurrent writer grows the file after fstat. */ +async function readBounded(handle: FileHandle): Promise { + const limit = FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes; + const buffer = Buffer.alloc(limit + 1); + let size = 0; + while (size < buffer.length) { + const read = await handle.read(buffer, size, buffer.length - size, size); + if (read.bytesRead === 0) break; + size += read.bytesRead; + } + return size > limit ? undefined : buffer.subarray(0, size); +} + +/** Called only after the owning executor has authorized and canonicalized both paths. */ +export async function prepareFileWrite( + cwd: string, + path: string, + content: string, + hostDirectories?: FileWriteDirectories, +): Promise { + const next = Buffer.from(content, 'utf8'); + if (next.length > FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes || next.toString('utf8') !== content) + return; + // Unsupported observations do not upgrade the existing conservative Write contract. + try { + const workspace = { + path: cwd, + identity: hostDirectories?.workspace ?? (await directoryIdentity(cwd)), + }; + const target = { + path, + parentIdentity: hostDirectories?.parent ?? (await directoryIdentity(dirname(path))), + }; + const metadata = await lstat(path, { bigint: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + let before: FileWriteObservation['before'] = { kind: 'missing' }; + let previous: string | undefined; + if (metadata) { + if ( + !metadata.isFile() || + metadata.isSymbolicLink() || + metadata.size > BigInt(FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes) + ) + return; + const handle = await open( + path, + constants.O_RDONLY | + (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK), + ); + try { + const opened = await handle.stat({ bigint: true }); + if (!opened.isFile() || !sameIdentity(identity(metadata), identity(opened))) return; + const bytes = await readBounded(handle); + if (!bytes || !Buffer.from(bytes.toString('utf8'), 'utf8').equals(bytes)) return; + if (await hostVisibilityAfterWrite(path, handle)) return; + previous = bytes.toString('utf8'); + before = { + kind: 'file', + identity: identity(opened), + bytes: bytes.length, + digest: contentDigest(bytes), + }; + } finally { + await handle.close(); + } + } + const observation: FileWriteObservation = { + contract: FILE_WRITE_EVIDENCE_CONTRACT, + workspace, + target, + before, + after: { bytes: next.length, digest: contentDigest(next) }, + result: buildFileWriteResult(path, content, previous ?? 'new'), + }; + return isFileWriteObservation(observation) ? observation : undefined; + } catch { + // Write-only grants, non-regular files and unavailable metadata remain legacy writes. + return undefined; + } +} + +/** Validate through the descriptor that will be mutated, before any truncate. */ +export async function executePreparedFileWrite( + cwd: string, + path: string, + content: string, + observation: FileWriteObservation, + hostDirectories?: FileWriteDirectories, +): Promise { + const changed = () => + new StableWriteFailure( + 'path_changed', + 'The prepared Write target changed; re-read before writing.', + ); + const next = Buffer.from(content, 'utf8'); + if ( + !isFileWriteObservation(observation) || + cwd !== observation.workspace.path || + path !== observation.target.path || + next.length !== observation.after.bytes || + contentDigest(next) !== observation.after.digest + ) + throw changed(); + if ( + !sameIdentity( + hostDirectories?.workspace ?? (await directoryIdentity(cwd)), + observation.workspace.identity, + ) || + !sameIdentity( + hostDirectories?.parent ?? (await directoryIdentity(dirname(path))), + observation.target.parentIdentity, + ) + ) + throw changed(); + const prior = observation.before; + // A missing target is created only here, after T1, using exclusive creation. + const handle = await openStableTarget({ + path, + approvedIdentity: prior.kind === 'file' ? prior.identity : undefined, + targetType: prior.kind === 'file' ? 'file' : 'missing', + }); + try { + // The worker's synthetic parent directories are not host identities. Its + // launcher already revalidated these and pins the approved mount sources. + const sameDirectories = + hostDirectories !== undefined || + (await Promise.all([directoryIdentity(cwd), directoryIdentity(dirname(path))]) + .then( + ([workspace, parent]) => + sameIdentity(workspace, observation.workspace.identity) && + sameIdentity(parent, observation.target.parentIdentity), + ) + .catch(() => false)); + if (!sameDirectories) { + if (prior.kind === 'missing') + throw new StableWriteFailure( + 'outcome_unknown', + 'The prepared Write directory changed after exclusive creation.', + ); + throw changed(); + } + if (prior.kind === 'file') { + const metadata = await handle.stat({ bigint: true }); + if (!metadata.isFile()) throw changed(); + const bytes = await readBounded(handle); + if (!bytes || bytes.length !== prior.bytes || contentDigest(bytes) !== prior.digest) + throw changed(); + const visibility = await hostVisibilityAfterWrite(path, handle); + if (visibility) throw changed(); + } + await writeThroughHandle(handle, content); + const visibility = await hostVisibilityAfterWrite(path, handle); + if (visibility) throw visibility; + return structuredClone(observation.result); + } finally { + await handle.close(); + } +} diff --git a/packages/runtime/src/file-write-lock.ts b/packages/runtime/src/file-write-lock.ts index 799d844017d..f9067319ad7 100644 --- a/packages/runtime/src/file-write-lock.ts +++ b/packages/runtime/src/file-write-lock.ts @@ -35,6 +35,40 @@ const tails = new Map>(); +/** A prepared write owns the same queue across preparation, T1 and execution. */ +export async function acquireFileWriteLock(key: string, signal?: AbortSignal): Promise<() => void> { + signal?.throwIfAborted(); + const previous = tails.get(key) ?? Promise.resolve(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => held); + tails.set(key, tail); + void tail.then(() => { + if (tails.get(key) === tail) tails.delete(key); + }); + let abort!: () => void; + const cancelled = new Promise((_, reject) => { + abort = () => { + release(); + reject(signal?.reason ?? new Error('Write preparation aborted')); + }; + signal?.addEventListener('abort', abort, { once: true }); + }); + try { + if (signal?.aborted) abort(); + await Promise.race([previous, cancelled]); + signal?.throwIfAborted(); + return release; + } catch (error) { + release(); + throw error; + } finally { + signal?.removeEventListener('abort', abort); + } +} + /** * Runs `fn` exclusively for `key`: it waits until any prior work for `key` * settles, then runs, then releases the key for the next waiter. Distinct keys diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 4875f97f2ba..6387ae4c3bc 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -29,6 +29,8 @@ // "full access" stricter than ask mode, which grants :slash_tmp outright (#2083). import { Buffer } from 'node:buffer'; +import { buildFileWriteResult } from './file-write-evidence.js'; +import type { FileWriteObservation } from '@maka/core/file-write-evidence'; import { readPage } from './read-page.js'; import { formatJsonText } from './format-json.js'; import { lstat, realpath, stat } from 'node:fs/promises'; @@ -45,7 +47,7 @@ import { } from './filesystem-authority.js'; import { StableWriteFailure } from './file-stable-write.js'; import { applyUpdateToContent } from './apply-patch-file.js'; -import { withFileWriteLock } from './file-write-lock.js'; +import { acquireFileWriteLock, withFileWriteLock } from './file-write-lock.js'; import type { FilesystemWorkerClient, FilesystemWorkerClientOperation, @@ -61,6 +63,7 @@ import type { WorkspaceApplyPatchExecutor, WorkspacePathScope, WorkspaceReadModifyWriteExecutor, + WorkspacePreparedWriteExecutor, WorkspaceSearchExecutor, WorkspaceWriteExecutor, } from './workspace-executor.js'; @@ -108,6 +111,9 @@ export interface ApplyPatchResult { } export interface FilesystemExecutor { + prepareWrite( + input: FilesystemExecuteInput & { operation: Extract }, + ): Promise; /** * Run one operation under the authority of the boundary it carries. A mutating * operation holds the target's write lock for its whole read-modify-write, so @@ -117,11 +123,18 @@ export interface FilesystemExecutor { applyPatch(input: FilesystemApplyPatchInput): Promise; } +export interface PreparedFilesystemWrite { + observation?: FileWriteObservation; + execute(): Promise; + cancel(): void; +} + /** The workspace primitives the host-local backend drives. */ export type FilesystemWorkspaceExecutor = WorkspaceWriteExecutor & WorkspaceEditExecutor & Partial & Partial & + Partial & WorkspaceSearchExecutor; export interface BoundaryFilesystemExecutorInput { @@ -288,6 +301,80 @@ export function createBoundaryFilesystemExecutor( return { key: normalized.enforcementPath, canonicalPath: normalized.enforcementPath }; } return { + async prepareWrite(input) { + const call = { + ...input, + ...(input.executionBoundary + ? { executionBoundary: structuredClone(input.executionBoundary) } + : {}), + }; + // Keep an immutable private copy: neither caller nor evidence consumers own execution args. + const operation = structuredClone(call.operation); + const { key, canonicalPath } = await writeLockTarget(call, operation.path); + const expectedIdentity = await captureIdentityAtLockAcquisition(canonicalPath, true); + const release = await acquireFileWriteLock(key, call.abortSignal); + let state: 'prepared' | 'executing' | 'closed' = 'prepared'; + const cancel = () => { + if (state !== 'prepared') return; + state = 'closed'; + call.abortSignal?.removeEventListener('abort', cancel); + release(); + }; + try { + call.abortSignal?.throwIfAborted(); + const prepared = await run( + { + ...call, + operation: { kind: 'prepare_write', path: operation.path, content: operation.content }, + }, + expectedIdentity, + ); + if (prepared.kind !== 'prepare_write') + throw new Error('Invalid Write preparation response'); + const observation = prepared.observation + ? structuredClone(prepared.observation) + : undefined; + if ( + observation && + (expectedIdentity + ? observation.before.kind !== 'file' || + observation.before.identity.dev !== expectedIdentity.dev || + observation.before.identity.ino !== expectedIdentity.ino + : observation.before.kind !== 'missing') + ) { + throw new StableWriteFailure( + 'path_changed', + 'The Write target changed while waiting for its lock.', + ); + } + call.abortSignal?.throwIfAborted(); + call.abortSignal?.addEventListener('abort', cancel, { once: true }); + return { + ...(observation ? { observation: structuredClone(observation) } : {}), + cancel, + async execute() { + if (state !== 'prepared') throw new Error('Prepared Write has already settled'); + call.abortSignal?.throwIfAborted(); + state = 'executing'; + call.abortSignal?.removeEventListener('abort', cancel); + try { + return await run( + { ...call, operation: { ...operation, ...(observation ? { observation } : {}) } }, + expectedIdentity, + ); + } catch (error) { + throw settleMutationFailure(error); + } finally { + state = 'closed'; + release(); + } + }, + }; + } catch (error) { + cancel(); + throw error; + } + }, async execute(call) { if (operationAccess(call.operation.kind) !== 'write') return await run(call); // Canonicalisation without any containment check, so a target the policy @@ -384,6 +471,18 @@ function createWorkspaceFilesystemExecutor( return { async execute({ operation, cwd, abortSignal }, scope, expectedIdentity) { switch (operation.kind) { + case 'prepare_write': { + const observation = + workspace.prepareWrite && workspace.executePreparedWrite + ? await workspace.prepareWrite({ + cwd, + path: operation.path, + content: operation.content, + scope, + }) + : undefined; + return { kind: 'prepare_write', ...(observation ? { observation } : {}) }; + } case 'read': { const { path } = await workspace.resolveExistingPath({ cwd, @@ -404,6 +503,17 @@ function createWorkspaceFilesystemExecutor( }; } case 'write': { + if (operation.observation) { + if (!workspace.executePreparedWrite) + throw new Error('Prepared Write executor unavailable'); + return workspace.executePreparedWrite({ + cwd, + path: operation.path, + content: operation.content, + scope, + observation: operation.observation, + }); + } const { path } = await workspace.resolveWritablePath({ cwd, path: operation.path, @@ -421,21 +531,7 @@ function createWorkspaceFilesystemExecutor( approvedIdentity: expectedIdentity, transform: () => operation.content, }); - const diff = - result.previous === 'unknown' - ? undefined - : createUnifiedDiff( - path, - result.previous === 'new' ? undefined : result.previous, - operation.content, - ); - return { - kind: 'write', - ok: true, - path, - bytes: Buffer.byteLength(operation.content, 'utf8'), - ...(diff !== undefined ? { diff } : {}), - }; + return buildFileWriteResult(path, operation.content, result.previous); } // Fallback (remote/isolated workspace): path-based, unprotected by // the identity authority. diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index 8d32e02a297..152db1dcd0d 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import type { FileWriteDirectories } from '@maka/core/file-write-evidence'; import { lstat, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -352,11 +353,72 @@ export class FilesystemWorkerClient { ...parsedOperation.data, path: target.enforcementPath, }); + // Linux mount namespaces may expose synthetic ancestor inodes. Keep the + // host identity bound to the actual mount source and verify host visibility. + let hostDirectories: FileWriteDirectories | undefined; + const captureDirectory = async (path: string) => { + const metadata = await lstat(path, { bigint: true }); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) + throw new Error('Directory changed'); + return { dev: String(metadata.dev), ino: String(metadata.ino) }; + }; + const directoriesUnchanged = async () => { + if (!hostDirectories) return true; + try { + const workspace = await captureDirectory(canonicalCwd); + const parent = await captureDirectory(dirname(target.enforcementPath)); + return ( + workspace.dev === hostDirectories.workspace.dev && + workspace.ino === hostDirectories.workspace.ino && + parent.dev === hostDirectories.parent.dev && + parent.ino === hostDirectories.parent.ino + ); + } catch { + return false; + } + }; + if ( + operation.kind === 'prepare_write' || + (operation.kind === 'write' && operation.observation) + ) { + try { + hostDirectories = { + workspace: await captureDirectory(canonicalCwd), + parent: await captureDirectory(dirname(target.enforcementPath)), + }; + } catch { + throw clientError( + 'path_changed', + 'validation', + requestId, + 'Write directories could not be observed.', + ); + } + if (operation.kind === 'write' && operation.observation) { + const observation = operation.observation; + if ( + observation.workspace.path !== canonicalCwd || + observation.target.path !== target.enforcementPath || + observation.workspace.identity.dev !== hostDirectories.workspace.dev || + observation.workspace.identity.ino !== hostDirectories.workspace.ino || + observation.target.parentIdentity.dev !== hostDirectories.parent.dev || + observation.target.parentIdentity.ino !== hostDirectories.parent.ino + ) { + throw clientError( + 'path_changed', + 'validation', + requestId, + 'The prepared Write directories changed.', + ); + } + } + } const request = { version: FILESYSTEM_WORKER_PROTOCOL_VERSION, requestId, operation, operationBoundary, + ...(hostDirectories ? { hostDirectories } : {}), expectedTarget: { enforcementPath: target.enforcementPath, access, @@ -369,7 +431,7 @@ export class FilesystemWorkerClient { identity: typeof writeIdentity === 'object' ? (identity ?? 'missing') : writeIdentity, }, } as const; - const requestJson = JSON.stringify(request); + let requestJson = JSON.stringify(request); if (Buffer.byteLength(requestJson, 'utf8') > FILESYSTEM_WORKER_MAX_REQUEST_BYTES) { throw clientError('request_overflow', 'validation', requestId); } @@ -511,6 +573,19 @@ export class FilesystemWorkerClient { pinnedMetadata.push({ ...pinned, path: metadata.path }); } } + if ( + hostDirectories && + pinnedRuntimeWritableRoot && + (String(pinnedRuntimeWritableRoot.device) !== hostDirectories.parent.dev || + String(pinnedRuntimeWritableRoot.inode) !== hostDirectories.parent.ino) + ) { + throw clientError( + 'path_changed', + 'validation', + requestId, + 'The prepared Write parent changed before pinning.', + ); + } transformed = this.input.sandboxManager.transform({ platform, command: { @@ -564,6 +639,22 @@ export class FilesystemWorkerClient { }); } + if (!(await directoriesUnchanged())) { + releasePinnedPaths(); + throw clientError( + 'path_changed', + 'validation', + requestId, + 'The prepared Write directories changed before launch.', + ); + } + // Native namespaces must validate their real directories inside the worker. + // Only Linux bind mounts need the host identities in place of synthetic ones. + requestJson = JSON.stringify({ + ...request, + hostDirectories: transformed.sandboxType === 'linux' ? hostDirectories : undefined, + }); + let processResult: Awaited>; try { processResult = await this.runProcess({ @@ -700,6 +791,17 @@ export class FilesystemWorkerClient { true, ); } + if (!(await directoriesUnchanged())) { + throw clientError( + operation.kind === 'write' ? 'outcome_unknown' : 'path_changed', + 'operation', + requestId, + 'The prepared Write directories changed while the worker ran.', + false, + {}, + true, + ); + } return response.result; } } diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index bc2dcfbfe24..db79743c509 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -18,7 +18,13 @@ */ import { searchFiles, GrepSearchError, type GrepRunner } from '../grep-search.js'; +import { + prepareFileWrite, + executePreparedFileWrite, + buildFileWriteResult, +} from '../file-write-evidence.js'; import { promises as fs } from 'node:fs'; +import type { FileWriteDirectories } from '@maka/core/file-write-evidence'; import { globFiles } from '../glob-search.js'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; import { isPathInside } from '../path-containment.js'; @@ -67,6 +73,8 @@ import { isLikelySandboxDenial } from '../sandbox/detect.js'; const { realpath, realpathAllowMissing, resolveCanonicalDirectoryEntryTarget } = sandboxPathApi(); export interface FilesystemWorkerOperationDependencies { + /** Host identities captured outside the sandbox mount namespace. */ + hostDirectories?: FileWriteDirectories; grepExecutable?: string; /** Where this worker runs, as the Host observed it; names the install location in Grep's guidance. */ ripgrepEnvironment?: RipgrepEnvironment; @@ -94,7 +102,7 @@ export async function executeFilesystemWorkerRequest( result: await executeFilesystemOperation( request.operation, request.operationBoundary, - dependencies, + { ...dependencies, hostDirectories: request.hostDirectories }, request.expectedTarget, ), }; @@ -116,6 +124,21 @@ export async function executeFilesystemOperation( expectedTarget?: FilesystemWorkerTarget, ): Promise { switch (operation.kind) { + case 'prepare_write': { + const path = await resolveWritableAllowed( + operation.cwd, + operation.path, + 'Write', + operationBoundary, + ); + const observation = await prepareFileWrite( + operation.cwd, + path, + operation.content, + dependencies.hostDirectories, + ); + return { kind: 'prepare_write', ...(observation ? { observation } : {}) }; + } case 'read': { const path = await resolveExistingAllowed( operation.cwd, @@ -158,6 +181,15 @@ export async function executeFilesystemOperation( 'Write', operationBoundary, ); + if (operation.observation) { + return executePreparedFileWrite( + operation.cwd, + path, + operation.content, + operation.observation, + dependencies.hostDirectories, + ); + } // Pin the approved object (#2600): open once, validate the identity on // the descriptor, and write through that descriptor — a path swap between // validation and the write cannot divert the bytes onto the replacement. @@ -190,17 +222,7 @@ export async function executeFilesystemOperation( // the bytes went to an orphan and the visible file is the replacement. const visibility = await hostVisibilityAfterWrite(path, handle); if (visibility) throw visibility; - const diff = - previous === 'unknown' - ? undefined - : createUnifiedDiff(path, previous === 'new' ? undefined : previous, operation.content); - return { - kind: 'write', - ok: true, - path, - bytes: Buffer.byteLength(operation.content, 'utf8'), - ...(diff !== undefined ? { diff } : {}), - }; + return buildFileWriteResult(path, operation.content, previous); } finally { await handle.close(); } diff --git a/packages/runtime/src/filesystem-worker/protocol.ts b/packages/runtime/src/filesystem-worker/protocol.ts index efacffe3bc1..abbb1d2c7e0 100644 --- a/packages/runtime/src/filesystem-worker/protocol.ts +++ b/packages/runtime/src/filesystem-worker/protocol.ts @@ -18,20 +18,24 @@ */ import { z } from 'zod'; +import { isFileWriteObservation, type FileWriteObservation } from '@maka/core/file-write-evidence'; import { readContinuationSchema, readPageSchema } from '../read-page.js'; import { validateSandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; import { GREP_MAX_LINES, GREP_MAX_LINES_PER_FILE, GREP_MAX_MATCH_BYTES } from '../grep-search.js'; -// v10 adds bounded Read pages to v9's exact Grep counts; v11 adds Glob's -// truncation flag to v10's result contract. Older workers cannot satisfy the -// combined result contract and must be rejected at the handshake. -export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 11 as const; +// v12 adds non-mutating Write preparation and the guarded execution observation. +// Older workers must not silently discard the guard on a prepared Write. +export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 12 as const; /** The single authority on which operation kinds are writes. Shared by the * client (permission/identity decisions) and the worker (operation guards) so * the set cannot drift. */ export function operationAccess(kind: FilesystemWorkerOperation['kind']): 'read' | 'write' { - return kind === 'write' || kind === 'apply_patch' || kind === 'edit' || kind === 'format_json' + return kind === 'write' || + kind === 'prepare_write' || + kind === 'apply_patch' || + kind === 'edit' || + kind === 'format_json' ? 'write' : 'read'; } @@ -112,7 +116,16 @@ export const FilesystemWorkerOperationSchema = z.union([ continuation: readContinuationSchema.optional(), }) .strict(), - z.object({ kind: z.literal('write'), cwd, path, content: z.string() }).strict(), + z + .object({ + kind: z.literal('write'), + cwd, + path, + content: z.string(), + observation: z.custom(isFileWriteObservation).optional(), + }) + .strict(), + z.object({ kind: z.literal('prepare_write'), cwd, path, content: z.string() }).strict(), z .object({ kind: z.literal('apply_patch'), @@ -170,10 +183,20 @@ export const FilesystemWorkerRequestSchema = z operation: FilesystemWorkerOperationSchema, operationBoundary: OperationBoundarySchema, expectedTarget: FilesystemWorkerTargetSchema, + hostDirectories: z + .object({ workspace: FilesystemTargetIdentitySchema, parent: FilesystemTargetIdentitySchema }) + .strict() + .optional(), }) .strict(); export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('prepare_write'), + observation: z.custom(isFileWriteObservation).optional(), + }) + .strict(), readPageSchema.extend({ kind: z.literal('read') }).strict(), z .object({ diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index eb9983a21f4..1dc727addfa 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -130,6 +130,8 @@ import { type RuntimeUserQuestionClosureReason, } from './interaction-authority.js'; +import { bindFileWriteEvidence, type FileWriteObservation } from '@maka/core/file-write-evidence'; + export interface ResolvedMakaToolCall { tool: MakaTool; turnId: string; @@ -168,6 +170,7 @@ export type MakaToolPreparationContext = Pick< >; export interface PreparedMakaToolExecution { + fileWriteObservation?: FileWriteObservation; execute(context: MakaToolContext): Promise | R; cancel(): Promise | void; } @@ -1402,17 +1405,20 @@ export class ToolRuntime { return this.errorReturn(reason); } - let clientCapabilityBoundary: ExecutionBoundary | undefined; - let clientCapabilityPermissionMode: PermissionMode | undefined; + let preparedBoundary: ExecutionBoundary | undefined; + let preparedPermissionMode: PermissionMode | undefined; let preparedExecution: PreparedMakaToolExecution | undefined; - if (tool.hostAdmission === 'client_capability') { + if ( + tool.hostAdmission === 'client_capability' || + (tool.prepareExecution && this.input.runtimeCommitSink) + ) { try { - clientCapabilityBoundary = await this.readExecutionBoundary(); - clientCapabilityPermissionMode = await this.livePermissionMode(clientCapabilityBoundary); + preparedBoundary = await this.readExecutionBoundary(); + preparedPermissionMode = await this.livePermissionMode(preparedBoundary); } catch (error) { const reason = formatSyntheticToolErrorText(error); await refuseBeforeDispatch(reason); - trace?.emit('tool', 'tool_failed', 'Client Capability boundary read failed', { + trace?.emit('tool', 'tool_failed', 'Tool preparation boundary read failed', { toolUseId, toolName: tool.name, status: 'error', @@ -1421,11 +1427,14 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(reason); } - const admissionFailure = !tool.prepareExecution - ? CLIENT_CAPABILITY_PREPARATION_MESSAGE - : clientCapabilityBoundary.kind !== 'bypass' && clientCapabilityPermissionMode !== 'ask' - ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE - : undefined; + const admissionFailure = + tool.hostAdmission !== 'client_capability' + ? undefined + : !tool.prepareExecution + ? CLIENT_CAPABILITY_PREPARATION_MESSAGE + : preparedBoundary.kind !== 'bypass' && preparedPermissionMode !== 'ask' + ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE + : undefined; if (admissionFailure) { await refuseBeforeDispatch(admissionFailure, { reason: 'requires_bypass', @@ -1440,8 +1449,8 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(admissionFailure); } - // Narrowed by admissionFailure above; Bypass still prepares so the - // provider cannot regain a direct pre-T1 dispatch path. + // Client capabilities always prepare. Durable filesystem tools also + // prepare before T1, under the same boundary used for execution. const prepareExecution = tool.prepareExecution!; const pauseTarget = this.input.getPermissionPauseTarget(); pauseTarget?.pause(); @@ -1451,22 +1460,31 @@ export class ToolRuntime { turnId, ...(runId ? { runId } : {}), cwd: this.input.header.cwd, - executionBoundary: clientCapabilityBoundary, - permissionMode: clientCapabilityPermissionMode, + executionBoundary: preparedBoundary, + permissionMode: preparedPermissionMode, toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); } catch (error) { const reason = formatSyntheticToolErrorText(error); - await refuseBeforeDispatch(reason); - trace?.emit('tool', 'tool_failed', 'Client Capability preparation failed', { + const sandbox = serializeSandboxError(error); + const boundaryKind = sandboxBoundaryFailureKind(sandbox); + const boundaryDetails = sandboxBoundaryFailureSignal(sandbox); + if (boundaryKind) + this.recordSandboxBoundaryFailure( + boundaryKind, + boundaryCorrectionStep, + sandboxBoundaryDecisionGeneration, + ); + await refuseBeforeDispatch(reason, boundaryDetails); + trace?.emit('tool', 'tool_failed', 'Tool preparation failed', { toolUseId, toolName: tool.name, status: 'error', - errorClass: 'ClientCapabilityPreparation', + errorClass: 'ToolPreparation', }); - this.recordLoopGateOutcome(callSignature, true); - return this.errorReturn(reason); + this.recordLoopGateOutcome(callSignature, true, boundaryKind, boundaryDetails); + return sandbox ? { error: reason, sandbox } : this.errorReturn(reason); } finally { pauseTarget?.resume(); } @@ -1490,6 +1508,7 @@ export class ToolRuntime { try { durableAttempt = await this.prepareDurableToolAttempt({ tool, + fileWriteObservation: preparedExecution?.fileWriteObservation, startEvent: buildCallEvent('dispatch'), persistedArgs, abortSignal: ctx.abortSignal, @@ -1501,11 +1520,6 @@ export class ToolRuntime { if (reservedSubagentSlot) this.releaseSubagentSlot(tool); throw error; } - publishCallEvent(buildCallEvent('dispatch')); - emitToolStartedTrace(); - if (durableAttempt) { - this.durableToolAttempts.set(durableAttemptKey(turnId, toolUseId), durableAttempt); - } const startedAt = this.input.now(); const output = createToolOutputDeltaEmitter({ sessionId: this.input.sessionId, @@ -1524,6 +1538,11 @@ export class ToolRuntime { let attemptBoundaryKind: SandboxBoundaryFailureKind | undefined; let attemptBoundaryDetails: SandboxBoundaryFailureDetails; try { + publishCallEvent(buildCallEvent('dispatch')); + emitToolStartedTrace(); + if (durableAttempt) { + this.durableToolAttempts.set(durableAttemptKey(turnId, toolUseId), durableAttempt); + } // Pause the stream idle watchdog for the whole tool execution. In the // ai-sdk step loop a tool runs *between* model requests — the tool-call // step's stream already finished and the next request has not started — @@ -1536,9 +1555,9 @@ export class ToolRuntime { pauseTarget?.pause(); try { const runId = this.input.runId; - const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); + const executionBoundary = preparedBoundary ?? (await this.readExecutionBoundary()); const permissionMode = - clientCapabilityPermissionMode ?? (await this.livePermissionMode(executionBoundary)); + preparedPermissionMode ?? (await this.livePermissionMode(executionBoundary)); const toolContext: MakaToolContext = { sessionId: this.input.sessionId, turnId, @@ -1897,6 +1916,7 @@ export class ToolRuntime { }); return sandboxError ? { error: msg, sandbox: sandboxError } : this.errorReturn(msg); } finally { + await preparedExecution?.cancel(); this.recordLoopGateOutcome( callSignature, attemptFailed, @@ -1909,6 +1929,7 @@ export class ToolRuntime { private async prepareDurableToolAttempt(input: { tool: MakaTool; + fileWriteObservation?: FileWriteObservation; startEvent: ToolStartEvent; persistedArgs: unknown; abortSignal: AbortSignal; @@ -1997,6 +2018,20 @@ export class ToolRuntime { toolName: input.tool.name, canonicalArgsHash, recoveryMode, + ...(input.fileWriteObservation + ? { + fileWriteEvidence: bindFileWriteEvidence(input.fileWriteObservation, { + sessionId: this.input.sessionId, + invocationId, + runId, + turnId: input.startEvent.turnId, + operationId, + dispatchEventId: `${operationId}_dispatch`, + providerToolCallId: input.startEvent.toolUseId, + canonicalArgsHash, + }), + } + : {}), }, }, refs: { diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 28a4a07fcee..7f98fabeb27 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -19,6 +19,8 @@ import { promises as fs } from 'node:fs'; import { exec } from 'node:child_process'; +import type { FileWriteObservation } from '@maka/core/file-write-evidence'; +import { prepareFileWrite, executePreparedFileWrite } from './file-write-evidence.js'; import { globFiles } from './glob-search.js'; import { isAbsolute, resolve } from 'node:path'; import { @@ -165,6 +167,22 @@ export interface WorkspaceReadModifyWriteExecutor { readModifyWrite(input: WorkspaceReadModifyWriteInput): Promise; } +export interface WorkspacePreparedWriteExecutor { + prepareWrite(input: { + cwd: string; + path: string; + content: string; + scope: WorkspacePathScope; + }): Promise; + executePreparedWrite(input: { + cwd: string; + path: string; + content: string; + scope: WorkspacePathScope; + observation: FileWriteObservation; + }): Promise; +} + /** * Which path space a resolution may land in. * @@ -351,6 +369,24 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { }; } + async prepareWrite( + input: Parameters[0], + ): Promise { + const cwd = await fs.realpath(input.cwd); + const { path } = await canonicalPathInScope(cwd, input.path, 'Write', input.scope); + // Match the local executor's binary/image result policy. + if (isSupportedImagePath(path)) return undefined; + return prepareFileWrite(cwd, path, input.content); + } + + async executePreparedWrite( + input: Parameters[0], + ): Promise { + const cwd = await fs.realpath(input.cwd); + const { path } = await canonicalPathInScope(cwd, input.path, 'Write', input.scope); + return executePreparedFileWrite(cwd, path, input.content, input.observation); + } + async readModifyWrite( input: WorkspaceReadModifyWriteInput, ): Promise { From 279ec943e4467cd7ed9c1b937c91ce04afa52570 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Tue, 13 Oct 2026 20:02:05 +0800 Subject: [PATCH 02/10] feat(runtime): complete Write/Edit evidence contracts Add Edit preparation and guarded execution, versioned mutation evidence, conversation-copy rebinding, and regression coverage for both tools. Refs #4319 Generated-by: OpenAI Codex --- ...hase4-workspace-checkpoint-design.zh-CN.md | 62 +-- packages/core/package.json | 2 +- ...-evidence.ts => file-mutation-evidence.ts} | 93 +++-- packages/core/src/runtime-event.ts | 15 +- .../builtin-tools-file-worker.test.ts | 3 +- .../src/__tests__/conversation-copy.test.ts | 147 +++++++- ...test.ts => file-mutation-evidence.test.ts} | 352 ++++++++++++++++-- .../filesystem-worker-client.test.ts | 53 ++- packages/runtime/src/builtin-tools.ts | 53 ++- packages/runtime/src/conversation-copy.ts | 24 ++ ...-evidence.ts => file-mutation-evidence.ts} | 154 ++++++-- packages/runtime/src/filesystem-executor.ts | 95 ++--- .../runtime/src/filesystem-worker/client.ts | 13 +- .../src/filesystem-worker/operations.ts | 43 ++- .../runtime/src/filesystem-worker/protocol.ts | 24 +- packages/runtime/src/tool-runtime.ts | 15 +- packages/runtime/src/workspace-executor.ts | 36 +- 17 files changed, 935 insertions(+), 249 deletions(-) rename packages/core/src/{file-write-evidence.ts => file-mutation-evidence.ts} (64%) rename packages/runtime/src/__tests__/{file-write-evidence.test.ts => file-mutation-evidence.test.ts} (54%) rename packages/runtime/src/{file-write-evidence.ts => file-mutation-evidence.ts} (60%) diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index 1de9af264f5..cf468239d7b 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -482,39 +482,45 @@ PR C 沿用 PR A 的 durable vocabulary:`matches_expected_state` 可 finalize `matches_prior_state`、`diverged`、`unreadable` 均提交 terminal parked decision。UI 直接展示 durable reason code(如 `reconcile_matches_prior_state`),不维护第二套展示层状态名。 -#### PR C 的首个实现切片:Write 的 T1 evidence +#### PR C 的首个实现切片:Write/Edit 的 T1 evidence -单文件 Write 的 evidence 由拥有文件权限的 executor 产生,位于 -`RuntimeEvent.actions.toolDispatch.fileWriteEvidence`。它随 call、dispatch 和 journal +单文件 Write/Edit 的 evidence 由拥有文件权限的 executor 产生,位于 +`RuntimeEvent.actions.toolDispatch.fileMutationEvidence`。它随 call、dispatch 和 journal 通过现有 `commitToolPrepared` 事务原子提交;不新增恢复数据库或独立持久化回调。 -固定 contract 为 `builtin.Write.utf8` version 1,transform 为 -`overwrite_utf8_v1`。记录包含 canonical cwd 的目录身份、canonical target 与父目录身份、 -before 的文件身份/字节数/SHA-256(或 missing)、expected-after 的字节数/SHA-256, -以及与普通 Write 共用构造逻辑的 worker result。外层 digest 还绑定 session、invocation、 -run、turn、operation、provider call、dispatch event 和 canonical args hash。 -decoder 拒绝未知 contract、digest 改写与跨操作替换。 - -执行顺序为:获取现有文件写锁 → 非变更 prepare → T1 → guarded write → T2。 -worker 协议 v12 增加 `prepare_write`,继续使用一次请求一个进程的生命周期。 -prepare 不创建 missing target,也不跨 T1 持有 worker 文件句柄。filesystem client 在宿主侧 -捕获目录身份,并在 dispatch 前及 worker 返回后复核。仅 Linux mount namespace 通过 request -传递宿主身份,其临时父目录 inode 不能作为宿主身份;missing target 的 pinned mount source -必须与证据中的父目录身份一致。原生 worker 直接复核实际目录。worker 在同一个已验证句柄上检查 before 内容并写入。missing target 只在 T1 后以 exclusive create -创建。取消、prepare/T1 失败和执行结束释放同一把写锁;prepared execution 只能消费一次。 +契约为 `builtin.Write.utf8` / `builtin.Edit.utf8` version 1,evidence kind 为 +`file_state_v1`,算法分别为 `overwrite_utf8_v1` / `unique_replace_utf8_v1`。 +记录包含 canonical cwd 的目录身份、canonical target 与父目录身份、before 的文件身份、 +字节数和 SHA-256(Write 允许 missing)、expected-after 的字节数和 SHA-256,以及 +production-shaped result。外层 digest 绑定 session、invocation、run、turn、operation、 +provider call、dispatch event 和 canonical args hash。decoder 拒绝未知 contract、算法、 +工具替换、digest 改写与跨操作替换;没有新字段的历史事件仍可读取。 + +执行顺序:获取现有写锁 → 非变更 prepare → T1 → guarded mutation → T2。 +worker 协议 v12 增加 `prepare_write` / `prepare_edit`,继续使用一次请求一个进程。 +prepare 不创建文件,也不跨 T1 持有 worker 文件句柄。Edit prepare 与执行都调用生产 +`computeEditedSource`,保留 exact/fuzzy 唯一匹配语义,并共享结果构造;执行前检查 +before,重新计算后核对 expected-after 与结果,再通过已验证句柄写入。 + +filesystem client 在宿主侧捕获目录身份,在 dispatch 前及 worker 返回后复核。 +仅 Linux mount namespace 通过 request 传递宿主身份;missing Write 的 pinned mount source +必须与证据中的父目录身份一致。原生 worker 直接复核目录。missing target 只在 T1 后 +exclusive create;Edit 不创建目标。取消、prepare/T1 失败和结束均释放写锁,execution 只能消费一次。 观察边界为可读取的 regular UTF-8 文件,before/after 各最多 1 MiB,result JSON 最多 -256 KiB;符号链接别名沿用既有 canonical target 规则,最终打开目标时不跟随新链接。 -不支持的观察(包括没有该能力的外部 executor)不记录 evidence,保留原 Write 执行路径。 -本地 backend 也保留其 image-path 不生成文本 diff 的规则。 - -这个切片仍固定 `never_auto_retry`,没有注册可执行 reconciler,也不调用 -`commitToolRecoveryBundle`。重启后可以读取 T1 evidence,但不能据此宣布恢复成功或自动重写。 -进程内锁不排斥 Bash 或外部程序;复核与实际写入之间的外部并发窗口仍然存在, -因此该 contract 不是通用 filesystem transaction 或跨进程 CAS。 - -测试入口:`packages/runtime/src/__tests__/file-write-evidence.test.ts`,覆盖 T1 内部回滚、 -T1/T2 间断点重开、外部改写、身份替换、取消/重复执行、证据替换及真实一次性 worker。 +256 KiB;沿用 canonical target 路径规则,最终打开时不跟随新链接。不支持的观察 +(包括没有此能力的外部 executor)不记录 evidence,沿用保守的普通执行路径。 +本地 backend 保留 image-path 策略。会话复制保留历史 observation,重新绑定新事件身份及 digest; +复制不会赋予执行权限,也不会改变 recovery mode。 + +本切片固定 `never_auto_retry`,没有 Host-owned executable contract registry、observer/reconciler、 +恢复扫描或 Host 生命周期接线,不调用 `commitToolRecoveryBundle`。重启后可读取 T1 evidence, +不能据此宣布恢复成功或自动重做。进程内锁不排斥 Bash 或外部程序,复核与写入之间仍有 +外部并发窗口;本契约不是通用 filesystem transaction 或跨进程 CAS。 + +测试入口:`packages/runtime/src/__tests__/file-mutation-evidence.test.ts` 与 +`conversation-copy.test.ts`,覆盖两种工具的 T1 回滚、崩溃窗口重开/rebuild、外部改写、 +身份/证据替换、取消、生产结果一致性、真实一次性 worker 及会话复制身份重绑定。 ### PR D — Host owner lifecycle diff --git a/packages/core/package.json b/packages/core/package.json index fb4a5992f4f..677e971858a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,7 +18,7 @@ "./runtime-logical-execution": "./dist/runtime-logical-execution.js", "./runtime-event": "./dist/runtime-event.js", "./tool-args-identity": "./dist/tool-args-identity.js", - "./file-write-evidence": "./dist/file-write-evidence.js", + "./file-mutation-evidence": "./dist/file-mutation-evidence.js", "./tool-ledger-scanner": "./dist/tool-ledger-scanner.js", "./tool-recovery-fact": "./dist/tool-recovery-fact.js", "./tool-recovery-bundle": "./dist/tool-recovery-bundle.js", diff --git a/packages/core/src/file-write-evidence.ts b/packages/core/src/file-mutation-evidence.ts similarity index 64% rename from packages/core/src/file-write-evidence.ts rename to packages/core/src/file-mutation-evidence.ts index def63295433..5d1a6eef231 100644 --- a/packages/core/src/file-write-evidence.ts +++ b/packages/core/src/file-mutation-evidence.ts @@ -20,39 +20,68 @@ import { createHash } from 'node:crypto'; import { stableJsonStringify } from './tool-args-identity.js'; -/** Observation contract only. Registration does not authorize reconciliation. */ +/** Versioned evidence contracts only; neither declaration authorizes recovery execution. */ export const FILE_WRITE_EVIDENCE_CONTRACT = Object.freeze({ id: 'builtin.Write.utf8', version: 1, + evidenceKind: 'file_state_v1', algorithm: 'overwrite_utf8_v1', maxFileBytes: 1_048_576, maxResultBytes: 262_144, pathPolicy: 'canonical_target_no_follow_v1', } as const); +export const FILE_EDIT_EVIDENCE_CONTRACT = Object.freeze({ + ...FILE_WRITE_EVIDENCE_CONTRACT, + id: 'builtin.Edit.utf8', + algorithm: 'unique_replace_utf8_v1', +} as const); + +export type FileMutation = + | { kind: 'write'; content: string } + | { kind: 'edit'; oldString: string; newString: string }; + +export interface FileWriteResult { + kind: 'write'; + ok: true; + path: string; + bytes: number; + diff?: string; +} +export interface FileEditResult { + kind: 'edit'; + ok: true; + path: string; + replacements: 1; + matchedVia: 'exact' | 'line-trimmed' | 'whitespace' | 'escape'; + startLine: number; + endLine: number; + diff?: string; +} + export interface FileIdentity { dev: string; ino: string; } /** Host identities; a sandbox may synthesize different directory inodes. */ -export interface FileWriteDirectories { +export interface FileMutationDirectories { workspace: FileIdentity; parent: FileIdentity; } -export interface FileWriteObservation { - contract: typeof FILE_WRITE_EVIDENCE_CONTRACT; +export interface FileMutationObservation { + contract: typeof FILE_WRITE_EVIDENCE_CONTRACT | typeof FILE_EDIT_EVIDENCE_CONTRACT; workspace: { path: string; identity: FileIdentity }; target: { path: string; parentIdentity: FileIdentity }; before: | { kind: 'missing' } | { kind: 'file'; identity: FileIdentity; bytes: number; digest: string }; after: { bytes: number; digest: string }; - result: { kind: 'write'; ok: true; path: string; bytes: number; diff?: string }; + result: FileWriteResult | FileEditResult; } -export interface FileWriteEvidenceBinding { +export interface FileMutationEvidenceBinding { sessionId: string; invocationId: string; runId: string; @@ -63,9 +92,9 @@ export interface FileWriteEvidenceBinding { canonicalArgsHash: string; } -export interface FileWriteEvidence { - observation: FileWriteObservation; - binding: FileWriteEvidenceBinding; +export interface FileMutationEvidence { + observation: FileMutationObservation; + binding: FileMutationEvidenceBinding; digest: string; } @@ -73,22 +102,22 @@ export function fileEvidenceDigest(value: unknown): string { return `sha256:${createHash('sha256').update(stableJsonStringify(value)).digest('hex')}`; } -export function bindFileWriteEvidence( - observation: FileWriteObservation, - binding: FileWriteEvidenceBinding, -): FileWriteEvidence { +export function bindFileMutationEvidence( + observation: FileMutationObservation, + binding: FileMutationEvidenceBinding, +): FileMutationEvidence { const body = structuredClone({ observation, binding }); - if (!isFileWriteObservation(body.observation) || !isBinding(body.binding)) { - throw new Error('Invalid file Write evidence'); + if (!isFileMutationObservation(body.observation) || !isBinding(body.binding)) { + throw new Error('Invalid file mutation evidence'); } return { ...body, digest: fileEvidenceDigest(body) }; } -export function isFileWriteEvidence(value: unknown): value is FileWriteEvidence { +export function isFileMutationEvidence(value: unknown): value is FileMutationEvidence { try { if (!record(value, ['observation', 'binding', 'digest'])) return false; return ( - isFileWriteObservation(value.observation) && + isFileMutationObservation(value.observation) && isBinding(value.binding) && value.digest === fileEvidenceDigest({ observation: value.observation, binding: value.binding }) @@ -98,7 +127,7 @@ export function isFileWriteEvidence(value: unknown): value is FileWriteEvidence } } -export function isFileWriteObservation(value: unknown): value is FileWriteObservation { +export function isFileMutationObservation(value: unknown): value is FileMutationObservation { try { return validateObservation(value); } catch { @@ -106,13 +135,14 @@ export function isFileWriteObservation(value: unknown): value is FileWriteObserv } } -function validateObservation(value: unknown): value is FileWriteObservation { +function validateObservation(value: unknown): value is FileMutationObservation { if (!record(value, ['contract', 'workspace', 'target', 'before', 'after', 'result'])) return false; const { contract, workspace, target, before, after, result } = value; return ( record(contract, Object.keys(FILE_WRITE_EVIDENCE_CONTRACT)) && - stableJsonStringify(contract) === stableJsonStringify(FILE_WRITE_EVIDENCE_CONTRACT) && + (stableJsonStringify(contract) === stableJsonStringify(FILE_WRITE_EVIDENCE_CONTRACT) || + stableJsonStringify(contract) === stableJsonStringify(FILE_EDIT_EVIDENCE_CONTRACT)) && record(workspace, ['path', 'identity']) && path(workspace.path) && identity(workspace.identity) && @@ -128,18 +158,33 @@ function validateObservation(value: unknown): value is FileWriteObservation { record(after, ['bytes', 'digest']) && bytes(after.bytes) && digest(after.digest) && - record(result, ['kind', 'ok', 'path', 'bytes'], ['diff']) && - result.kind === 'write' && + ((contract.id === FILE_WRITE_EVIDENCE_CONTRACT.id && + record(result, ['kind', 'ok', 'path', 'bytes'], ['diff']) && + result.kind === 'write' && + result.bytes === after.bytes) || + (contract.id === FILE_EDIT_EVIDENCE_CONTRACT.id && + before.kind === 'file' && + record( + result, + ['kind', 'ok', 'path', 'replacements', 'matchedVia', 'startLine', 'endLine'], + ['diff'], + ) && + result.kind === 'edit' && + result.replacements === 1 && + ['exact', 'line-trimmed', 'whitespace', 'escape'].includes(result.matchedVia as string) && + Number.isSafeInteger(result.startLine) && + (result.startLine as number) >= 1 && + Number.isSafeInteger(result.endLine) && + (result.endLine as number) >= (result.startLine as number))) && result.ok === true && result.path === target.path && - result.bytes === after.bytes && (result.diff === undefined || typeof result.diff === 'string') && Buffer.byteLength(stableJsonStringify(result), 'utf8') <= FILE_WRITE_EVIDENCE_CONTRACT.maxResultBytes ); } -function isBinding(value: unknown): value is FileWriteEvidenceBinding { +function isBinding(value: unknown): value is FileMutationEvidenceBinding { return ( record(value, [ 'sessionId', diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 5df9720bd8f..59dd6417fde 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -34,7 +34,7 @@ */ import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; -import { isFileWriteEvidence, type FileWriteEvidence } from './file-write-evidence.js'; +import { isFileMutationEvidence, type FileMutationEvidence } from './file-mutation-evidence.js'; import { isToolCallOutcome } from './tool-result-status.js'; import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js'; @@ -447,7 +447,7 @@ export interface RuntimeEventToolDispatch { /** T1-frozen managed workspace mutation identity. */ managedMutation?: RuntimeEventManagedWorkspaceMutationV2; /** Trusted pre-dispatch observation; does not by itself authorize replay. */ - fileWriteEvidence?: FileWriteEvidence; + fileMutationEvidence?: FileMutationEvidence; } /** @@ -916,7 +916,7 @@ const RUNTIME_TOOL_DISPATCH_SHAPE = defineObjectShape( 'canonicalArgsHash', 'recoveryMode', ], - ['managedMutation', 'resultProjectionVersion', 'fileWriteEvidence'], + ['managedMutation', 'resultProjectionVersion', 'fileMutationEvidence'], ); const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = defineObjectShape()( @@ -1037,10 +1037,11 @@ export function decodeRuntimeEvent(value: unknown): RuntimeEvent { runtimeHandoffPause(value as unknown as RuntimeEvent); const event = value as unknown as RuntimeEvent; const dispatch = event.actions?.toolDispatch; - if (dispatch?.fileWriteEvidence) { - const binding = dispatch.fileWriteEvidence.binding; + if (dispatch?.fileMutationEvidence) { + const binding = dispatch.fileMutationEvidence.binding; if ( - dispatch.toolName !== 'Write' || + dispatch.toolName !== + (dispatch.fileMutationEvidence.observation.result.kind === 'write' ? 'Write' : 'Edit') || dispatch.managedMutation !== undefined || dispatch.recoveryMode !== 'never_auto_retry' || binding.sessionId !== event.sessionId || @@ -1462,7 +1463,7 @@ function isRuntimeToolDispatch(value: unknown): value is RuntimeEventToolDispatc value.recoveryMode === 'never_auto_retry') && (value.managedMutation === undefined || isRuntimeManagedWorkspaceMutation(value.managedMutation)) && - (value.fileWriteEvidence === undefined || isFileWriteEvidence(value.fileWriteEvidence)) + (value.fileMutationEvidence === undefined || isFileMutationEvidence(value.fileMutationEvidence)) ); } diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts index b1f2f44e380..20fb3dba318 100644 --- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts @@ -186,7 +186,8 @@ describe('builtin file tools use the sandboxed worker', () => { calls.push(input); switch (input.operation.kind) { case 'prepare_write': - return { kind: 'prepare_write' }; + case 'prepare_edit': + return { kind: input.operation.kind }; case 'read': return { kind: 'read', diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 5824608ac0c..3bd96a42da6 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -17,10 +17,16 @@ * under the License. */ +import { + bindFileMutationEvidence, + isFileMutationEvidence, +} from '@maka/core/file-mutation-evidence'; +import { prepareFileMutation } from '../file-mutation-evidence.js'; +import { decodeRuntimeEvent } from '@maka/core/runtime-event'; import assert from 'node:assert/strict'; import { readToolResultPage } from '../read-page.js'; import { createLedgerArchiveResourceReader } from '../ledger-tool-result-archive-reader.js'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -4212,3 +4218,142 @@ function invocationOpenedEvent(run: SeededRun = {}): RuntimeEvent { opening: invocationOpening(run), }); } + +for (const kind of ['write', 'edit'] as const) { + test(`conversation copy rebinds ${kind} evidence without changing its historical observation`, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-file-evidence-copy-')); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + await runStore.ready?.(); + const path = join(root, 'notes.txt'); + await writeFile(path, 'before'); + const mutation = + kind === 'write' + ? { kind, content: 'after' } + : { kind, oldString: 'before', newString: 'after' }; + const observation = await prepareFileMutation(root, path, mutation); + assert.ok(observation); + const toolName = kind === 'write' ? 'Write' : 'Edit'; + const args = + kind === 'write' + ? { path, content: 'after' } + : { path, old_string: 'before', new_string: 'after' }; + const dispatch = runtimeEvent({ + id: 'event-dispatch', + ts: 3, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName, + canonicalArgsHash: canonicalToolArgsHash(toolName, args), + recoveryMode: 'never_auto_retry', + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }); + const fact = dispatch.actions!.toolDispatch!; + fact.fileMutationEvidence = bindFileMutationEvidence(observation, { + sessionId: dispatch.sessionId, + invocationId: dispatch.invocationId, + runId: dispatch.runId, + turnId: dispatch.turnId, + operationId: fact.operationId, + dispatchEventId: dispatch.id, + providerToolCallId: fact.providerToolCallId, + canonicalArgsHash: fact.canonicalArgsHash, + }); + const sourceEvidence = structuredClone(fact.fileMutationEvidence); + const events = [ + invocationOpenedEvent({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }), + runtimeEvent({ + id: 'event-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'copy files' }, + }), + runtimeEvent({ + id: 'event-call', + ts: 2, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'provider-call-1', + name: toolName, + args, + }, + }), + dispatch, + runtimeEvent({ + id: 'event-outcome', + ts: 4, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: toolName, + result: { kind: 'text', text: 'ok' }, + isError: false, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }), + runtimeEvent({ id: 'event-terminal', ts: 5, status: 'completed' }), + ]; + await runtimeEventStore.importConversationCopyRuntimeEvents('session-source', [ + { runId: 'run-source', events }, + ]); + const source = await new RuntimeReadModel({ runtimeEventStore }).getSessionView( + 'session-source', + ); + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + const [run] = await runtimeEventStore.listSessionInvocations('session-target'); + assert.ok(run); + const copied = await runtimeEventStore.readRuntimeEvents('session-target', run.runId); + const target = copied.find((event) => event.actions?.toolDispatch)!; + const evidence = target.actions!.toolDispatch!.fileMutationEvidence!; + assert.ok(isFileMutationEvidence(evidence)); + assert.deepEqual(evidence.observation, sourceEvidence.observation); + assert.notEqual(evidence.digest, sourceEvidence.digest); + assert.equal(evidence.binding.sessionId, 'session-target'); + assert.equal(evidence.binding.runId, run.runId); + assert.equal(evidence.binding.invocationId, target.invocationId); + assert.equal(evidence.binding.dispatchEventId, target.id); + assert.equal(evidence.binding.operationId, target.actions!.toolDispatch!.operationId); + assert.notEqual(evidence.binding.operationId, sourceEvidence.binding.operationId); + assert.equal(target.actions!.toolDispatch!.recoveryMode, 'never_auto_retry'); + assert.doesNotThrow(() => decodeRuntimeEvent(target)); + await runtimeEventStore.rebuildToolProjectionsFromRuntimeEvents(); + assert.deepEqual( + (await runtimeEventStore.readRuntimeEvents('session-source', 'run-source')).find( + (event) => event.actions?.toolDispatch, + )!.actions!.toolDispatch!.fileMutationEvidence, + sourceEvidence, + ); + } finally { + runtimeEventStore.close(); + runStore.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/runtime/src/__tests__/file-write-evidence.test.ts b/packages/runtime/src/__tests__/file-mutation-evidence.test.ts similarity index 54% rename from packages/runtime/src/__tests__/file-write-evidence.test.ts rename to packages/runtime/src/__tests__/file-mutation-evidence.test.ts index 1819959b5f2..366da3e1685 100644 --- a/packages/runtime/src/__tests__/file-write-evidence.test.ts +++ b/packages/runtime/src/__tests__/file-mutation-evidence.test.ts @@ -25,24 +25,30 @@ import { fileURLToPath } from 'node:url'; import { test, type TestContext } from 'node:test'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { - isFileWriteEvidence, + isFileMutationEvidence, fileEvidenceDigest, FILE_WRITE_EVIDENCE_CONTRACT, -} from '@maka/core/file-write-evidence'; + FILE_EDIT_EVIDENCE_CONTRACT, +} from '@maka/core/file-mutation-evidence'; import { decodeRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeCommitSink, ToolPreparedCommit } from '../runtime-commit-sink.js'; import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; import { buildBuiltinTools } from '../builtin-tools.js'; import { createLocalWorkspaceExecutor } from '../workspace-executor.js'; import { createBoundaryFilesystemExecutor } from '../filesystem-executor.js'; -import { executePreparedFileWrite, prepareFileWrite } from '../file-write-evidence.js'; +import { + executePreparedFileWrite, + prepareFileWrite, + prepareFileMutation, + executePreparedFileMutation, +} from '../file-mutation-evidence.js'; import { runFilesystemWorkerProcess } from '../filesystem-worker/process-runner.js'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION, parseFilesystemWorkerResponse, } from '../filesystem-worker/protocol.js'; -async function fixture(t: TestContext) { +async function fixture(t: TestContext, kind: 'write' | 'edit' = 'write') { const cwd = await realpath(await mkdtemp(join(tmpdir(), 'maka-write-evidence-'))); const path = join(cwd, 'notes.txt'); const dbPath = join(cwd, 'runtime.sqlite'); @@ -53,9 +59,14 @@ async function fixture(t: TestContext) { }); const executor = createLocalWorkspaceExecutor(); const filesystem = createBoundaryFilesystemExecutor({ workspace: executor }); - const tool = buildBuiltinTools({ executor }).find((candidate) => candidate.name === 'Write')!; + const tool = buildBuiltinTools({ executor }).find( + (candidate) => candidate.name === (kind === 'write' ? 'Write' : 'Edit'), + )!; const call = (signal?: AbortSignal) => ({ - operation: { kind: 'write' as const, path, content: 'after\n' }, + operation: + kind === 'write' + ? { kind: 'write' as const, path, content: 'after\n' } + : { kind: 'edit' as const, path, oldString: 'before', newString: 'after' }, cwd, executionBoundary: { kind: 'bypass' as const, revision: 0 }, abortSignal: signal, @@ -98,7 +109,10 @@ async function fixture(t: TestContext) { tool, turnId: 'turn-1', toolCallId: 'provider-call-1', - input: { path, content: 'after\n' }, + input: + kind === 'write' + ? { path, content: 'after\n' } + : { path, old_string: 'before', new_string: 'after' }, abortSignal, eventSink: { push: () => {}, pushAndWaitUntilConsumed: async () => {} }, }); @@ -124,12 +138,14 @@ for (const existing of [false, true]) { /T1 unavailable/, ); assert.ok( - isFileWriteEvidence(prepared?.dispatchRuntimeEvent.actions?.toolDispatch?.fileWriteEvidence), + isFileMutationEvidence( + prepared?.dispatchRuntimeEvent.actions?.toolDispatch?.fileMutationEvidence, + ), ); if (existing) assert.equal(await readFile(f.path, 'utf8'), 'before\n'); else await assert.rejects(readFile(f.path), { code: 'ENOENT' }); assert.deepEqual(await f.store.readRuntimeEvents('session-1', 'run-1'), []); - const retry = await f.filesystem.prepareWrite(f.call()); + const retry = await f.filesystem.prepareMutation(f.call()); retry.cancel(); }); } @@ -154,8 +170,8 @@ test('ordinary Write persists bound evidence and keeps the production diff', asy assert.equal(await readFile(f.path, 'utf8'), 'after\n'); const events = await f.store.readRuntimeEvents('session-1', 'run-1'); const dispatch = events.find((event) => event.actions?.toolDispatch)!; - const evidence = dispatch.actions!.toolDispatch!.fileWriteEvidence!; - assert.ok(isFileWriteEvidence(evidence)); + const evidence = dispatch.actions!.toolDispatch!.fileMutationEvidence!; + assert.ok(isFileMutationEvidence(evidence)); assert.equal(evidence.binding.dispatchEventId, dispatch.id); assert.equal( evidence.binding.canonicalArgsHash, @@ -187,7 +203,7 @@ for (const point of ['after_runtime_event_insert', 'after_journal_event_insert'] } finally { reopened.close(); } - const next = await f.filesystem.prepareWrite(f.call()); + const next = await f.filesystem.prepareMutation(f.call()); next.cancel(); }); } @@ -216,7 +232,7 @@ for (const point of ['after_t1', 'before_t2'] as const) { await reopened.rebuildToolProjectionsFromRuntimeEvents(); const events = await reopened.readRuntimeEvents('session-1', 'run-1'); const dispatch = events.find((event) => event.actions?.toolDispatch)!; - assert.ok(isFileWriteEvidence(dispatch.actions!.toolDispatch!.fileWriteEvidence)); + assert.ok(isFileMutationEvidence(dispatch.actions!.toolDispatch!.fileMutationEvidence)); assert.equal( (await reopened.readToolOperation(dispatch.actions!.toolDispatch!.operationId)) ?.currentState, @@ -226,7 +242,7 @@ for (const point of ['after_t1', 'before_t2'] as const) { } finally { reopened.close(); } - const next = await f.filesystem.prepareWrite(f.call()); + const next = await f.filesystem.prepareMutation(f.call()); next.cancel(); }); } @@ -242,17 +258,17 @@ test('an in-place external edit after T1 is rejected before truncation', async ( }, commitToolOutcome: (input) => f.store.commitToolOutcome(input), }); - assert.match(JSON.stringify(result), /prepared Write target changed/); + assert.match(JSON.stringify(result), /prepared file mutation target changed/); assert.equal(await readFile(f.path, 'utf8'), 'external edit\n'); }); test('prepared writes reject inode replacement and a file appearing at a missing target', async (t) => { const f = await fixture(t); - const missing = await f.filesystem.prepareWrite(f.call()); + const missing = await f.filesystem.prepareMutation(f.call()); await writeFile(f.path, 'external create'); await assert.rejects(missing.execute(), /appeared|created/); assert.equal(await readFile(f.path, 'utf8'), 'external create'); - const existing = await f.filesystem.prepareWrite(f.call()); + const existing = await f.filesystem.prepareMutation(f.call()); await rename(f.path, join(f.cwd, 'old.txt')); await writeFile(f.path, 'replacement'); await assert.rejects(existing.execute(), /changed/); @@ -263,15 +279,15 @@ test('prepared writes reject inode replacement and a file appearing at a missing test('cancellation and duplicate execution cannot leak a prepared lock or write twice', async (t) => { const f = await fixture(t); const controller = new AbortController(); - const first = await f.filesystem.prepareWrite(f.call(controller.signal)); + const first = await f.filesystem.prepareMutation(f.call(controller.signal)); const waitingController = new AbortController(); - const waiting = f.filesystem.prepareWrite(f.call(waitingController.signal)); + const waiting = f.filesystem.prepareMutation(f.call(waitingController.signal)); waitingController.abort(new Error('cancel waiting')); await assert.rejects(waiting, /cancel waiting/); controller.abort(new Error('cancel prepared')); await assert.rejects(first.execute(), /already settled/); first.cancel(); - const next = await f.filesystem.prepareWrite(f.call()); + const next = await f.filesystem.prepareMutation(f.call()); await next.execute(); await writeFile(f.path, 'later edit'); await assert.rejects(next.execute(), /already settled/); @@ -282,8 +298,9 @@ test('cancellation and duplicate execution cannot leak a prepared lock or write test('observation and caller argument mutation cannot change the prepared execution', async (t) => { const f = await fixture(t); const call = f.call(); - const prepared = await f.filesystem.prepareWrite(call); - call.operation.content = 'wrong'; + const prepared = await f.filesystem.prepareMutation(call); + assert.equal(call.operation.kind, 'write'); + if (call.operation.kind === 'write') call.operation.content = 'wrong'; prepared.observation!.after.digest = `sha256:${'0'.repeat(64)}`; await prepared.execute(); assert.equal(await readFile(f.path, 'utf8'), 'after\n'); @@ -301,17 +318,17 @@ test('invalid UTF-8, oversized content, and unsupported executor observations st ), undefined, ); - const prepared = await f.filesystem.prepareWrite(f.call()); + const prepared = await f.filesystem.prepareMutation(f.call()); assert.equal(prepared.observation, undefined); await prepared.execute(); assert.equal(await readFile(f.path, 'utf8'), 'after\n'); const unsupported = createBoundaryFilesystemExecutor({ workspace: Object.assign(createLocalWorkspaceExecutor(), { - prepareWrite: undefined, - executePreparedWrite: undefined, + prepareMutation: undefined, + executePreparedMutation: undefined, }), }); - const fallback = await unsupported.prepareWrite(f.call()); + const fallback = await unsupported.prepareMutation(f.call()); assert.equal(fallback.observation, undefined); await fallback.execute(); }); @@ -333,7 +350,7 @@ test('evidence tampering, contract drift and cross-operation substitution are re ); for (const mutation of ['digest', 'version', 'binding'] as const) { const bad = structuredClone(prepared); - const evidence = bad.dispatchRuntimeEvent.actions!.toolDispatch!.fileWriteEvidence!; + const evidence = bad.dispatchRuntimeEvent.actions!.toolDispatch!.fileMutationEvidence!; if (mutation === 'digest') evidence.observation.after.bytes++; if (mutation === 'version') Object.assign(evidence.observation.contract, { version: 2 }); if (mutation === 'binding') { @@ -420,10 +437,10 @@ test('a failed preparation worker releases the lock without dispatching a write' const call = f.call(); // The embedding default routes to its configured worker. const { executionBoundary: _boundary, ...workerCall } = call; - await assert.rejects(filesystem.prepareWrite(workerCall), /worker died/); + await assert.rejects(filesystem.prepareMutation(workerCall), /worker died/); assert.deepEqual(operations, ['prepare_write']); await assert.rejects(readFile(f.path), { code: 'ENOENT' }); - const next = await f.filesystem.prepareWrite(f.call()); + const next = await f.filesystem.prepareMutation(f.call()); next.cancel(); }); @@ -431,7 +448,7 @@ test('prepared and ordinary local Write retain identical result shapes', async ( const f = await fixture(t); for (const previous of ['', 'before', 'new', 'unknown']) { await writeFile(f.path, previous); - const prepared = await f.filesystem.prepareWrite(f.call()); + const prepared = await f.filesystem.prepareMutation(f.call()); const result = await prepared.execute(); await writeFile(f.path, previous); assert.deepEqual(await f.filesystem.execute(f.call()), result); @@ -439,7 +456,7 @@ test('prepared and ordinary local Write retain identical result shapes', async ( const call = f.call(); call.operation.path = join(f.cwd, 'image.png'); await writeFile(call.operation.path, 'text with an image extension'); - const prepared = await f.filesystem.prepareWrite(call); + const prepared = await f.filesystem.prepareMutation(call); assert.equal(prepared.observation, undefined); const result = await prepared.execute(); assert.equal(result.kind === 'write' && result.diff, undefined); @@ -455,3 +472,276 @@ test('prepared primitive refuses content substitution before creating a file', a ); await assert.rejects(readFile(f.path), { code: 'ENOENT' }); }); + +for (const point of ['fail_t1', 'after_t1', 'before_t2', 'external_change'] as const) { + test(`Edit evidence survives ${point} without an unproven write`, async (t) => { + const f = await fixture(t, 'edit'); + await writeFile(f.path, 'before\n'); + let prepared!: ToolPreparedCommit; + const promise = f.execute({ + commitToolPrepared: async (input) => { + prepared = structuredClone(input); + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + assert.deepEqual( + input.dispatchRuntimeEvent.actions?.toolDispatch?.fileMutationEvidence?.observation + .contract, + FILE_EDIT_EVIDENCE_CONTRACT, + ); + if (point === 'fail_t1') throw new Error('injected failure'); + const result = await f.store.commitToolPrepared(input); + if (point === 'after_t1') throw new Error('injected failure'); + if (point === 'external_change') await writeFile(f.path, 'outside\n'); + return result; + }, + commitToolOutcome: async (input) => { + if (point === 'before_t2') throw new Error('injected failure'); + return f.store.commitToolOutcome(input); + }, + }); + if (point === 'external_change') assert.match(JSON.stringify(await promise), /target changed/); + else await assert.rejects(promise, /injected failure/); + assert.equal( + await readFile(f.path, 'utf8'), + point === 'before_t2' ? 'after\n' : point === 'external_change' ? 'outside\n' : 'before\n', + ); + assert.ok( + isFileMutationEvidence( + prepared.dispatchRuntimeEvent.actions?.toolDispatch?.fileMutationEvidence, + ), + ); + f.store.close(); + const reopened = createSqliteRuntimeStore(f.dbPath); + try { + await reopened.rebuildToolProjectionsFromRuntimeEvents(); + const events = await reopened.readRuntimeEvents('session-1', 'run-1'); + if (point === 'fail_t1') assert.deepEqual(events, []); + else { + const dispatch = events.find((event) => event.actions?.toolDispatch)!; + assert.ok(isFileMutationEvidence(dispatch.actions!.toolDispatch!.fileMutationEvidence)); + assert.equal(dispatch.actions!.toolDispatch!.recoveryMode, 'never_auto_retry'); + } + } finally { + reopened.close(); + } + const retry = await f.filesystem.prepareMutation(f.call()); + retry.cancel(); + }); +} + +for (const point of ['after_runtime_event_insert', 'after_journal_event_insert'] as const) { + test(`Edit evidence is rolled back atomically at ${point}`, async (t) => { + const f = await fixture(t, 'edit'); + await writeFile(f.path, 'before\n'); + const broken = createSqliteRuntimeStore(join(f.cwd, 'fault.sqlite'), { + failpoint: (reached) => { + if (reached === point) throw new Error('T1 rollback'); + }, + }); + try { + await assert.rejects(f.execute(broken), /T1 rollback/); + assert.deepEqual(await broken.readRuntimeEvents('session-1', 'run-1'), []); + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + } finally { + broken.close(); + } + }); +} + +test('prepared Edit keeps exact and fuzzy production results and rejects ambiguous matches', async (t) => { + const f = await fixture(t, 'edit'); + for (const [source, oldString, newString, matchedVia] of [ + ['before\n', 'before', 'after', 'exact'], + [' first line\n second line\n', 'first line\nsecond line', 'new text', 'line-trimmed'], + ['const value = 1;\n', 'const value = 1;', 'const value = 2;', 'whitespace'], + ['line1\nline2', 'line1\\nline2', 'next', 'escape'], + ] as const) { + const call = { + ...f.call(), + operation: { + kind: 'edit' as const, + path: f.path, + oldString: oldString!, + newString: newString!, + }, + }; + await writeFile(f.path, source!); + const prepared = await f.filesystem.prepareMutation(call); + assert.ok(prepared.observation); + assert.equal(await readFile(f.path, 'utf8'), source); + const result = await prepared.execute(); + assert.equal(result.kind, 'edit'); + if (result.kind === 'edit') assert.equal(result.matchedVia, matchedVia); + const after = await readFile(f.path, 'utf8'); + await writeFile(f.path, source!); + assert.deepEqual(await f.filesystem.execute(call), result); + assert.equal(await readFile(f.path, 'utf8'), after); + } + await writeFile(f.path, 'before before'); + const ambiguous = await f.filesystem.prepareMutation(f.call()); + assert.equal(ambiguous.observation, undefined); + await assert.rejects(ambiguous.execute(), /not unique/); + assert.equal(await readFile(f.path, 'utf8'), 'before before'); +}); + +test('Edit cancellation, identity substitution and duplicate execution preserve outside content', async (t) => { + const f = await fixture(t, 'edit'); + await writeFile(f.path, 'before\n'); + const controller = new AbortController(); + const cancelled = await f.filesystem.prepareMutation(f.call(controller.signal)); + controller.abort(); + await assert.rejects(cancelled.execute(), /already settled/); + const replaced = await f.filesystem.prepareMutation(f.call()); + await rename(f.path, join(f.cwd, 'original')); + await writeFile(f.path, 'before\n'); + await assert.rejects(replaced.execute(), /changed/); + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + const once = await f.filesystem.prepareMutation(f.call()); + await once.execute(); + await writeFile(f.path, 'outside'); + await assert.rejects(once.execute(), /already settled/); + assert.equal(await readFile(f.path, 'utf8'), 'outside'); +}); + +test('Edit contracts reject missing prior state, tool substitution and unknown algorithms', async (t) => { + const f = await fixture(t, 'edit'); + await writeFile(f.path, 'before\n'); + let prepared!: ToolPreparedCommit; + await assert.rejects( + f.execute({ + commitToolPrepared: async (input) => { + prepared = structuredClone(input); + throw new Error('capture'); + }, + commitToolOutcome: async () => { + throw new Error('unexpected'); + }, + }), + /capture/, + ); + for (const change of ['missing', 'algorithm', 'tool', 'result'] as const) { + const event = structuredClone(prepared.dispatchRuntimeEvent); + const dispatch = event.actions!.toolDispatch!; + const evidence = dispatch.fileMutationEvidence!; + if (change === 'missing') evidence.observation.before = { kind: 'missing' }; + if (change === 'algorithm') + Object.assign(evidence.observation.contract, { algorithm: 'unknown_v2' }); + if (change === 'tool') dispatch.toolName = 'Write'; + if (change === 'result') Object.assign(evidence.observation.result, { matchedVia: 'unknown' }); + evidence.digest = fileEvidenceDigest({ + observation: evidence.observation, + binding: evidence.binding, + }); + assert.throws(() => decodeRuntimeEvent(event)); + } + // Historical dispatches without the optional new field remain decodable. + const legacy = structuredClone(prepared.dispatchRuntimeEvent); + delete legacy.actions!.toolDispatch!.fileMutationEvidence; + assert.equal(decodeRuntimeEvent(legacy).id, legacy.id); +}); + +test('ordinary Edit persists evidence with T1 and publishes its production result at T2', async (t) => { + const f = await fixture(t, 'edit'); + await writeFile(f.path, 'before\n'); + const expected = await prepareFileMutation(f.cwd, f.path, { + kind: 'edit', + oldString: 'before', + newString: 'after', + }); + assert.ok(expected); + const settled = await f.execute(); + assert.deepEqual(settled.result, { + kind: 'file_diff', + paths: [f.path], + diff: expected.result.diff, + }); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); + const events = await f.store.readRuntimeEvents('session-1', 'run-1'); + const evidence = events.find((event) => event.actions?.toolDispatch)!.actions!.toolDispatch! + .fileMutationEvidence!; + assert.ok(isFileMutationEvidence(evidence)); + assert.equal(evidence.observation.result.kind, 'edit'); + assert.ok(events.some((event) => event.content?.kind === 'function_response')); +}); + +test('real one-shot worker prepares and executes Edit with the exact same result', async (t) => { + const f = await fixture(t, 'edit'); + await writeFile(f.path, 'before\n'); + const metadata = await import('node:fs/promises').then((fs) => fs.stat(f.path, { bigint: true })); + const request = { + version: FILESYSTEM_WORKER_PROTOCOL_VERSION, + requestId: 'edit-prepare', + operation: { + kind: 'prepare_edit', + cwd: f.cwd, + path: f.path, + oldString: 'before', + newString: 'after', + }, + operationBoundary: { + filesystem: { entries: [{ path: f.path, access: 'write', scope: 'exact' }] }, + }, + expectedTarget: { + enforcementPath: f.path, + access: 'write', + scope: 'exact', + targetType: 'file', + identity: { dev: String(metadata.dev), ino: String(metadata.ino) }, + }, + }; + const run = async (body: unknown) => { + const result = await runFilesystemWorkerProcess({ + argv: [ + process.execPath, + fileURLToPath(new URL('../filesystem-worker/worker-entry.js', import.meta.url)), + ], + cwd: f.cwd, + env: process.env, + stdin: JSON.stringify(body), + }); + assert.equal(result.exitCode, 0, result.stderrTail); + return parseFilesystemWorkerResponse(JSON.parse(result.stdout)); + }; + const prepared = await run(request); + assert.ok(prepared.ok && prepared.result.kind === 'prepare_edit' && prepared.result.observation); + const observation = prepared.result.observation; + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + const bad = await run({ + ...request, + operation: { ...request.operation, kind: 'edit', newString: 'wrong', observation }, + }); + assert.ok(!bad.ok && bad.error.code === 'path_changed'); + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + const executed = await run({ + ...request, + operation: { ...request.operation, kind: 'edit', observation }, + }); + assert.ok(executed.ok); + assert.deepEqual(executed.result, observation.result); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); +}); + +test('unsupported Edit evidence preserves legacy behavior without upgrading recovery', async (t) => { + const f = await fixture(t, 'edit'); + const mutation = { kind: 'edit' as const, oldString: 'before', newString: 'after' }; + assert.equal(await prepareFileMutation(f.cwd, f.path, mutation), undefined); + const missing = await f.filesystem.prepareMutation(f.call()); + await assert.rejects(missing.execute()); + await assert.rejects(readFile(f.path), { code: 'ENOENT' }); + await writeFile(f.path, Buffer.from([0xff, 0xfe])); + assert.equal(await prepareFileMutation(f.cwd, f.path, mutation), undefined); + await writeFile(f.path, 'before'); + assert.equal( + await prepareFileMutation(f.cwd, f.path, { + ...mutation, + newString: 'x'.repeat(FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes + 1), + }), + undefined, + ); + const observation = await prepareFileMutation(f.cwd, f.path, mutation); + assert.ok(observation); + await assert.rejects( + executePreparedFileMutation(f.cwd, f.path, { ...mutation, newString: 'wrong' }, observation), + /changed/, + ); + assert.equal(await readFile(f.path, 'utf8'), 'before'); +}); diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index 32c22643400..dd1ba19cefb 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -36,7 +36,7 @@ import { afterEach, describe, test } from 'node:test'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; -import { prepareFileWrite } from '../file-write-evidence.js'; +import { prepareFileWrite } from '../file-mutation-evidence.js'; import { executeFilesystemWorkerRequest } from '../filesystem-worker/operations.js'; import { canReadPath, @@ -101,6 +101,44 @@ describe('filesystem worker client permission snapshots', () => { assert.equal(requests[0]!.hostDirectories, undefined); }); + test('Edit preparation uses the execution write grant and existing identity', async () => { + const cwd = await temporaryDirectory('maka-edit-prepare-grant-'); + const path = join(cwd, 'notes.txt'); + await writeFile(path, 'before'); + const stat = await lstat(path, { bigint: true }); + const { client, requests } = fakeClient(); + for (const kind of ['prepare_edit', 'edit'] as const) { + await client.execute({ + operation: { kind, path, oldString: 'before', newString: 'after' }, + cwd, + mode: 'ask', + expectedIdentity: { dev: String(stat.dev), ino: String(stat.ino) }, + }); + } + assert.deepEqual(requests[0]!.operationBoundary, requests[1]!.operationBoundary); + assert.deepEqual(requests[0]!.expectedTarget, requests[1]!.expectedTarget); + assert.equal(requests[0]!.expectedTarget.access, 'write'); + assert.equal(requests[0]!.hostDirectories, undefined); + }); + + test('a read-only profile cannot prepare an Edit', async () => { + const cwd = await temporaryDirectory('maka-edit-prepare-denied-'); + const path = join(cwd, 'notes.txt'); + await writeFile(path, 'before'); + const { client, requests } = fakeClient(); + await assert.rejects( + client.execute({ + operation: { kind: 'prepare_edit', path, oldString: 'before', newString: 'after' }, + cwd, + permissionProfile: createReadOnlyPermissionProfile(), + expectedIdentity: 'unchecked', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && error.reason === 'path_denied', + ); + assert.deepEqual(requests, []); + }); + test('a prepared Write rejects substituted host directory identity before worker launch', async () => { const workspace = await temporaryDirectory('maka-worker-prepare-host-'); const path = join(workspace, 'notes.txt'); @@ -897,7 +935,18 @@ function fakeClient( function fakeResult(request: FilesystemWorkerRequest): FilesystemWorkerResult { switch (request.operation.kind) { case 'prepare_write': - return { kind: 'prepare_write' }; + case 'prepare_edit': + return { kind: request.operation.kind }; + case 'edit': + return { + kind: 'edit', + ok: true, + path: request.operation.path, + replacements: 1, + matchedVia: 'exact', + startLine: 1, + endLine: 1, + }; case 'read': return request.operation.path.endsWith('.png') ? { kind: 'read_image', base64: 'iVBORw==', mimeType: 'image/png' } diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index e286ae99455..cdbe3dc01a4 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -431,12 +431,12 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT }), executionFacts, prepareExecution: async ({ path, content }, ctx) => { - const prepared = await filesystem.prepareWrite({ + const prepared = await filesystem.prepareMutation({ operation: { kind: 'write', path, content }, ...filesystemCall(ctx), }); return { - ...(prepared.observation ? { fileWriteObservation: prepared.observation } : {}), + ...(prepared.observation ? { fileMutationObservation: prepared.observation } : {}), cancel: () => prepared.cancel(), execute: async () => writeToolOutput(await prepared.execute()), }; @@ -465,6 +465,17 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT new_string: z.string(), }), executionFacts, + prepareExecution: async ({ path, old_string, new_string }, ctx) => { + const prepared = await filesystem.prepareMutation({ + operation: { kind: 'edit', path, oldString: old_string, newString: new_string }, + ...filesystemCall(ctx), + }); + return { + ...(prepared.observation ? { fileMutationObservation: prepared.observation } : {}), + cancel: () => prepared.cancel(), + execute: async () => editToolOutput(await prepared.execute()), + }; + }, impl: async ({ path, old_string, new_string }, ctx) => { const result = await filesystem.execute({ operation: { @@ -475,22 +486,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT }, ...filesystemCall(ctx), }); - if (result.kind !== 'edit') - throw internalFilesystemWriteFailure( - 'Edit', - 'the edit was applied', - 'a different old_string will not help', - ); - if (result.diff !== undefined) - return { kind: 'file_diff' as const, paths: [result.path], diff: result.diff }; - return { - ok: result.ok, - path: result.path, - replacements: result.replacements, - matchedVia: result.matchedVia, - startLine: result.startLine, - endLine: result.endLine, - }; + return editToolOutput(result); }, toModelOutput: ({ output }) => fileWriteToolResultToModelOutput('Edit', output), }, @@ -638,6 +634,27 @@ function writeToolOutput( return { kind: 'file_write' as const, path: result.path, bytes: result.bytes }; } +function editToolOutput( + result: Awaited['execute']>>, +) { + if (result.kind !== 'edit') + throw internalFilesystemWriteFailure( + 'Edit', + 'the edit was applied', + 'a different old_string will not help', + ); + if (result.diff !== undefined) + return { kind: 'file_diff' as const, paths: [result.path], diff: result.diff }; + return { + ok: result.ok, + path: result.path, + replacements: result.replacements, + matchedVia: result.matchedVia, + startLine: result.startLine, + endLine: result.endLine, + }; +} + interface ExecutorBashSandboxOptions { permissionProfile?: PermissionProfile; sandboxManager?: SandboxManager; diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 32f154a4687..971881a7b1c 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -17,6 +17,8 @@ * under the License. */ +import { bindFileMutationEvidence } from '@maka/core/file-mutation-evidence'; + import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; import { isPartialRuntimeEvent, @@ -1637,6 +1639,28 @@ function cloneRuntimeEvent( sessionId: ids.sessionId, runId: ids.runId, }; + const dispatch = cloned.actions?.toolDispatch; + if (dispatch?.fileMutationEvidence) { + // Preserve the historical observation while rebinding the copied fact. This + // does not authorize a new filesystem effect or upgrade its recovery mode. + encodeCanonicalRuntimeEvent(event); + cloned.actions = { + ...cloned.actions, + toolDispatch: { + ...dispatch, + fileMutationEvidence: bindFileMutationEvidence(dispatch.fileMutationEvidence.observation, { + sessionId: cloned.sessionId, + invocationId: cloned.invocationId, + runId: cloned.runId, + turnId: cloned.turnId, + dispatchEventId: cloned.id, + operationId: dispatch.operationId, + providerToolCallId: dispatch.providerToolCallId, + canonicalArgsHash: dispatch.canonicalArgsHash, + }), + }, + }; + } const accepted = event.actions?.permissionAnswerAccepted; const decision = accepted ? copiedPermissionDecisions.get(accepted.requestId) : undefined; if (!decision || !cloned.actions) return cloned; diff --git a/packages/runtime/src/file-write-evidence.ts b/packages/runtime/src/file-mutation-evidence.ts similarity index 60% rename from packages/runtime/src/file-write-evidence.ts rename to packages/runtime/src/file-mutation-evidence.ts index 95dfdc48513..424117ff5ee 100644 --- a/packages/runtime/src/file-write-evidence.ts +++ b/packages/runtime/src/file-mutation-evidence.ts @@ -23,12 +23,18 @@ import { lstat, open, type FileHandle } from 'node:fs/promises'; import { dirname } from 'node:path'; import { FILE_WRITE_EVIDENCE_CONTRACT, - isFileWriteObservation, + FILE_EDIT_EVIDENCE_CONTRACT, + fileEvidenceDigest, + type FileMutation, + type FileWriteResult, + type FileEditResult, + isFileMutationObservation, type FileIdentity, - type FileWriteDirectories, - type FileWriteObservation, -} from '@maka/core/file-write-evidence'; -import { createUnifiedDiff } from './unified-diff.js'; + type FileMutationDirectories, + type FileMutationObservation, +} from '@maka/core/file-mutation-evidence'; +import { createUnifiedDiff, createEditUnifiedDiff } from './unified-diff.js'; +import { computeEditedSource, type EditMatch } from './edit-replace.js'; import { hostVisibilityAfterWrite, openStableTarget, @@ -53,7 +59,7 @@ export function buildFileWriteResult( path: string, content: string, previous: string, -): FileWriteObservation['result'] { +): FileWriteResult { const diff = previous === 'unknown' ? undefined @@ -87,17 +93,43 @@ async function readBounded(handle: FileHandle): Promise { return size > limit ? undefined : buffer.subarray(0, size); } +export function buildFileEditResult( + path: string, + previous: string, + edited: EditMatch, +): FileEditResult { + const diff = createEditUnifiedDiff(path, previous, edited.content, edited); + return { + kind: 'edit', + ok: true, + path, + replacements: 1, + matchedVia: edited.matchedVia, + startLine: edited.startLine, + endLine: edited.endLine, + ...(diff === undefined ? {} : { diff }), + }; +} + +function transform(path: string, mutation: FileMutation, previous: string | undefined) { + if (mutation.kind === 'write') + return { + content: mutation.content, + result: buildFileWriteResult(path, mutation.content, previous ?? 'new'), + }; + if (previous === undefined) throw new Error('Edit requires an existing file'); + const edited = computeEditedSource(previous, mutation.oldString, mutation.newString, path); + return { content: edited.content, result: buildFileEditResult(path, previous, edited) }; +} + /** Called only after the owning executor has authorized and canonicalized both paths. */ -export async function prepareFileWrite( +export async function prepareFileMutation( cwd: string, path: string, - content: string, - hostDirectories?: FileWriteDirectories, -): Promise { - const next = Buffer.from(content, 'utf8'); - if (next.length > FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes || next.toString('utf8') !== content) - return; - // Unsupported observations do not upgrade the existing conservative Write contract. + mutation: FileMutation, + hostDirectories?: FileMutationDirectories, +): Promise { + // Unsupported observations retain the conservative ordinary mutation path. try { const workspace = { path: cwd, @@ -111,7 +143,7 @@ export async function prepareFileWrite( if (error.code === 'ENOENT') return undefined; throw error; }); - let before: FileWriteObservation['before'] = { kind: 'missing' }; + let before: FileMutationObservation['before'] = { kind: 'missing' }; let previous: string | undefined; if (metadata) { if ( @@ -142,41 +174,62 @@ export async function prepareFileWrite( await handle.close(); } } - const observation: FileWriteObservation = { - contract: FILE_WRITE_EVIDENCE_CONTRACT, + const next = transform(path, mutation, previous); + const bytes = Buffer.from(next.content, 'utf8'); + if ( + bytes.length > FILE_WRITE_EVIDENCE_CONTRACT.maxFileBytes || + bytes.toString('utf8') !== next.content + ) + return; + const observation: FileMutationObservation = { + contract: + mutation.kind === 'write' ? FILE_WRITE_EVIDENCE_CONTRACT : FILE_EDIT_EVIDENCE_CONTRACT, workspace, target, before, - after: { bytes: next.length, digest: contentDigest(next) }, - result: buildFileWriteResult(path, content, previous ?? 'new'), + after: { bytes: bytes.length, digest: contentDigest(bytes) }, + result: next.result, }; - return isFileWriteObservation(observation) ? observation : undefined; + return isFileMutationObservation(observation) ? observation : undefined; } catch { - // Write-only grants, non-regular files and unavailable metadata remain legacy writes. return undefined; } } -/** Validate through the descriptor that will be mutated, before any truncate. */ -export async function executePreparedFileWrite( +export function prepareFileWrite( cwd: string, path: string, content: string, - observation: FileWriteObservation, - hostDirectories?: FileWriteDirectories, -): Promise { + directories?: FileMutationDirectories, +) { + return prepareFileMutation(cwd, path, { kind: 'write', content }, directories); +} + +/** Validate through the descriptor that will be mutated, before any truncate. */ +export async function executePreparedFileMutation( + cwd: string, + path: string, + mutation: FileMutation, + observation: FileMutationObservation, + hostDirectories?: FileMutationDirectories, +): Promise { const changed = () => new StableWriteFailure( 'path_changed', - 'The prepared Write target changed; re-read before writing.', + 'The prepared file mutation target changed; re-read before writing.', ); - const next = Buffer.from(content, 'utf8'); + const matchesAfter = (content: string) => { + const bytes = Buffer.from(content, 'utf8'); + return ( + bytes.length === observation.after.bytes && contentDigest(bytes) === observation.after.digest + ); + }; if ( - !isFileWriteObservation(observation) || + !isFileMutationObservation(observation) || cwd !== observation.workspace.path || path !== observation.target.path || - next.length !== observation.after.bytes || - contentDigest(next) !== observation.after.digest + mutation.kind !== observation.result.kind || + (mutation.kind === 'write' && !matchesAfter(mutation.content)) ) throw changed(); if ( @@ -191,15 +244,15 @@ export async function executePreparedFileWrite( ) throw changed(); const prior = observation.before; - // A missing target is created only here, after T1, using exclusive creation. + // A missing Write target is created only here, after T1, using exclusive creation. const handle = await openStableTarget({ path, approvedIdentity: prior.kind === 'file' ? prior.identity : undefined, targetType: prior.kind === 'file' ? 'file' : 'missing', }); try { - // The worker's synthetic parent directories are not host identities. Its - // launcher already revalidated these and pins the approved mount sources. + // Only Linux mount namespaces receive hostDirectories; their launcher validates + // pinned mount sources and host visibility before dispatch and after completion. const sameDirectories = hostDirectories !== undefined || (await Promise.all([directoryIdentity(cwd), directoryIdentity(dirname(path))]) @@ -213,24 +266,47 @@ export async function executePreparedFileWrite( if (prior.kind === 'missing') throw new StableWriteFailure( 'outcome_unknown', - 'The prepared Write directory changed after exclusive creation.', + 'The prepared directory changed after exclusive creation.', ); throw changed(); } + let previous: string | undefined; if (prior.kind === 'file') { const metadata = await handle.stat({ bigint: true }); if (!metadata.isFile()) throw changed(); const bytes = await readBounded(handle); if (!bytes || bytes.length !== prior.bytes || contentDigest(bytes) !== prior.digest) throw changed(); - const visibility = await hostVisibilityAfterWrite(path, handle); - if (visibility) throw changed(); + if (await hostVisibilityAfterWrite(path, handle)) throw changed(); + previous = bytes.toString('utf8'); } - await writeThroughHandle(handle, content); + const next = transform(path, mutation, previous); + if ( + !matchesAfter(next.content) || + fileEvidenceDigest(next.result) !== fileEvidenceDigest(observation.result) + ) + throw changed(); + await writeThroughHandle(handle, next.content); const visibility = await hostVisibilityAfterWrite(path, handle); if (visibility) throw visibility; - return structuredClone(observation.result); + return structuredClone(next.result); } finally { await handle.close(); } } + +export function executePreparedFileWrite( + cwd: string, + path: string, + content: string, + observation: FileMutationObservation, + directories?: FileMutationDirectories, +) { + return executePreparedFileMutation( + cwd, + path, + { kind: 'write', content }, + observation, + directories, + ); +} diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 6387ae4c3bc..9de162fdff0 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -29,8 +29,8 @@ // "full access" stricter than ask mode, which grants :slash_tmp outright (#2083). import { Buffer } from 'node:buffer'; -import { buildFileWriteResult } from './file-write-evidence.js'; -import type { FileWriteObservation } from '@maka/core/file-write-evidence'; +import { buildFileWriteResult, buildFileEditResult } from './file-mutation-evidence.js'; +import type { FileMutationObservation } from '@maka/core/file-mutation-evidence'; import { readPage } from './read-page.js'; import { formatJsonText } from './format-json.js'; import { lstat, realpath, stat } from 'node:fs/promises'; @@ -40,7 +40,7 @@ import type { PermissionMode } from '@maka/core/permission'; import type { PermissionProfile } from '@maka/core/permission-profile'; import { ToolOutcomeUnknownError } from '@maka/core/events'; import { computeEditedSource } from './edit-replace.js'; -import { createEditUnifiedDiff, createUnifiedDiff } from './unified-diff.js'; +import { createUnifiedDiff } from './unified-diff.js'; import { classifyFailedMutationOutcome, type FilesystemTargetIdentity, @@ -63,7 +63,7 @@ import type { WorkspaceApplyPatchExecutor, WorkspacePathScope, WorkspaceReadModifyWriteExecutor, - WorkspacePreparedWriteExecutor, + WorkspacePreparedMutationExecutor, WorkspaceSearchExecutor, WorkspaceWriteExecutor, } from './workspace-executor.js'; @@ -111,9 +111,11 @@ export interface ApplyPatchResult { } export interface FilesystemExecutor { - prepareWrite( - input: FilesystemExecuteInput & { operation: Extract }, - ): Promise; + prepareMutation( + input: FilesystemExecuteInput & { + operation: Extract; + }, + ): Promise; /** * Run one operation under the authority of the boundary it carries. A mutating * operation holds the target's write lock for its whole read-modify-write, so @@ -123,8 +125,8 @@ export interface FilesystemExecutor { applyPatch(input: FilesystemApplyPatchInput): Promise; } -export interface PreparedFilesystemWrite { - observation?: FileWriteObservation; +export interface PreparedFilesystemMutation { + observation?: FileMutationObservation; execute(): Promise; cancel(): void; } @@ -134,7 +136,7 @@ export type FilesystemWorkspaceExecutor = WorkspaceWriteExecutor & WorkspaceEditExecutor & Partial & Partial & - Partial & + Partial & WorkspaceSearchExecutor; export interface BoundaryFilesystemExecutorInput { @@ -301,7 +303,7 @@ export function createBoundaryFilesystemExecutor( return { key: normalized.enforcementPath, canonicalPath: normalized.enforcementPath }; } return { - async prepareWrite(input) { + async prepareMutation(input) { const call = { ...input, ...(input.executionBoundary @@ -325,11 +327,19 @@ export function createBoundaryFilesystemExecutor( const prepared = await run( { ...call, - operation: { kind: 'prepare_write', path: operation.path, content: operation.content }, + operation: + operation.kind === 'write' + ? { kind: 'prepare_write', path: operation.path, content: operation.content } + : { + kind: 'prepare_edit', + path: operation.path, + oldString: operation.oldString, + newString: operation.newString, + }, }, expectedIdentity, ); - if (prepared.kind !== 'prepare_write') + if (prepared.kind !== (operation.kind === 'write' ? 'prepare_write' : 'prepare_edit')) throw new Error('Invalid Write preparation response'); const observation = prepared.observation ? structuredClone(prepared.observation) @@ -471,17 +481,25 @@ function createWorkspaceFilesystemExecutor( return { async execute({ operation, cwd, abortSignal }, scope, expectedIdentity) { switch (operation.kind) { - case 'prepare_write': { + case 'prepare_write': + case 'prepare_edit': { const observation = - workspace.prepareWrite && workspace.executePreparedWrite - ? await workspace.prepareWrite({ + workspace.prepareMutation && workspace.executePreparedMutation + ? await workspace.prepareMutation({ cwd, path: operation.path, - content: operation.content, + mutation: + operation.kind === 'prepare_write' + ? { kind: 'write', content: operation.content } + : { + kind: 'edit', + oldString: operation.oldString, + newString: operation.newString, + }, scope, }) : undefined; - return { kind: 'prepare_write', ...(observation ? { observation } : {}) }; + return { kind: operation.kind, ...(observation ? { observation } : {}) }; } case 'read': { const { path } = await workspace.resolveExistingPath({ @@ -504,12 +522,12 @@ function createWorkspaceFilesystemExecutor( } case 'write': { if (operation.observation) { - if (!workspace.executePreparedWrite) + if (!workspace.executePreparedMutation) throw new Error('Prepared Write executor unavailable'); - return workspace.executePreparedWrite({ + return workspace.executePreparedMutation({ cwd, path: operation.path, - content: operation.content, + mutation: operation, scope, observation: operation.observation, }); @@ -593,6 +611,17 @@ function createWorkspaceFilesystemExecutor( return { kind: 'apply_patch', ok: true, path: patched.path }; } case 'edit': { + if (operation.observation) { + if (!workspace.executePreparedMutation) + throw new Error('Prepared Edit executor unavailable'); + return workspace.executePreparedMutation({ + cwd, + path: operation.path, + mutation: operation, + scope, + observation: operation.observation, + }); + } const { path } = await workspace.resolveExistingPath({ cwd, path: operation.path, @@ -620,17 +649,7 @@ function createWorkspaceFilesystemExecutor( return edited.content; }, }); - const diff = createEditUnifiedDiff(path, originalContent, edited.content, edited); - return { - kind: 'edit', - ok: true, - path, - replacements: 1, - matchedVia: edited.matchedVia, - startLine: edited.startLine, - endLine: edited.endLine, - ...(diff !== undefined ? { diff } : {}), - }; + return buildFileEditResult(path, originalContent, edited); } const read = await workspace.readFile({ cwd, path }); if ('bytes' in read) throw new Error('Edit does not support image files.'); @@ -641,17 +660,7 @@ function createWorkspaceFilesystemExecutor( operation.path, ); await workspace.writeFile({ cwd, path, content: edited.content }); - const diff = createEditUnifiedDiff(path, read.content, edited.content, edited); - return { - kind: 'edit', - ok: true, - path, - replacements: 1, - matchedVia: edited.matchedVia, - startLine: edited.startLine, - endLine: edited.endLine, - ...(diff !== undefined ? { diff } : {}), - }; + return buildFileEditResult(path, read.content, edited); } case 'format_json': { const { path } = await workspace.resolveExistingPath({ diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index 152db1dcd0d..4d1b2b8a2dd 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import type { FileWriteDirectories } from '@maka/core/file-write-evidence'; +import type { FileMutationDirectories } from '@maka/core/file-mutation-evidence'; import { lstat, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -355,7 +355,7 @@ export class FilesystemWorkerClient { }); // Linux mount namespaces may expose synthetic ancestor inodes. Keep the // host identity bound to the actual mount source and verify host visibility. - let hostDirectories: FileWriteDirectories | undefined; + let hostDirectories: FileMutationDirectories | undefined; const captureDirectory = async (path: string) => { const metadata = await lstat(path, { bigint: true }); if (!metadata.isDirectory() || metadata.isSymbolicLink()) @@ -379,7 +379,8 @@ export class FilesystemWorkerClient { }; if ( operation.kind === 'prepare_write' || - (operation.kind === 'write' && operation.observation) + operation.kind === 'prepare_edit' || + ((operation.kind === 'write' || operation.kind === 'edit') && operation.observation) ) { try { hostDirectories = { @@ -394,7 +395,7 @@ export class FilesystemWorkerClient { 'Write directories could not be observed.', ); } - if (operation.kind === 'write' && operation.observation) { + if ((operation.kind === 'write' || operation.kind === 'edit') && operation.observation) { const observation = operation.observation; if ( observation.workspace.path !== canonicalCwd || @@ -793,7 +794,9 @@ export class FilesystemWorkerClient { } if (!(await directoriesUnchanged())) { throw clientError( - operation.kind === 'write' ? 'outcome_unknown' : 'path_changed', + operation.kind === 'write' || operation.kind === 'edit' + ? 'outcome_unknown' + : 'path_changed', 'operation', requestId, 'The prepared Write directories changed while the worker ran.', diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index db79743c509..82203edb914 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -19,12 +19,14 @@ import { searchFiles, GrepSearchError, type GrepRunner } from '../grep-search.js'; import { - prepareFileWrite, + prepareFileMutation, + executePreparedFileMutation, + buildFileEditResult, executePreparedFileWrite, buildFileWriteResult, -} from '../file-write-evidence.js'; +} from '../file-mutation-evidence.js'; import { promises as fs } from 'node:fs'; -import type { FileWriteDirectories } from '@maka/core/file-write-evidence'; +import type { FileMutationDirectories } from '@maka/core/file-mutation-evidence'; import { globFiles } from '../glob-search.js'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; import { isPathInside } from '../path-containment.js'; @@ -44,7 +46,7 @@ import { import { computeEditedSource } from '../edit-replace.js'; import { readPage } from '../read-page.js'; import { formatJsonText } from '../format-json.js'; -import { createEditUnifiedDiff, createUnifiedDiff } from '../unified-diff.js'; +import { createUnifiedDiff } from '../unified-diff.js'; import { compareAndDeleteEntry, hostVisibilityAfterWrite, @@ -74,7 +76,7 @@ const { realpath, realpathAllowMissing, resolveCanonicalDirectoryEntryTarget } = export interface FilesystemWorkerOperationDependencies { /** Host identities captured outside the sandbox mount namespace. */ - hostDirectories?: FileWriteDirectories; + hostDirectories?: FileMutationDirectories; grepExecutable?: string; /** Where this worker runs, as the Host observed it; names the install location in Grep's guidance. */ ripgrepEnvironment?: RipgrepEnvironment; @@ -124,20 +126,23 @@ export async function executeFilesystemOperation( expectedTarget?: FilesystemWorkerTarget, ): Promise { switch (operation.kind) { - case 'prepare_write': { + case 'prepare_write': + case 'prepare_edit': { const path = await resolveWritableAllowed( operation.cwd, operation.path, 'Write', operationBoundary, ); - const observation = await prepareFileWrite( + const observation = await prepareFileMutation( operation.cwd, path, - operation.content, + operation.kind === 'prepare_write' + ? { kind: 'write', content: operation.content } + : { kind: 'edit', oldString: operation.oldString, newString: operation.newString }, dependencies.hostDirectories, ); - return { kind: 'prepare_write', ...(observation ? { observation } : {}) }; + return { kind: operation.kind, ...(observation ? { observation } : {}) }; } case 'read': { const path = await resolveExistingAllowed( @@ -282,6 +287,14 @@ export async function executeFilesystemOperation( 'write', operationBoundary, ); + if (operation.observation) + return executePreparedFileMutation( + operation.cwd, + path, + operation, + operation.observation, + dependencies.hostDirectories, + ); const handle = await openStableTarget({ path, approvedIdentity: @@ -307,17 +320,7 @@ export async function executeFilesystemOperation( await writeThroughHandle(handle, source.content); const visibility = await hostVisibilityAfterWrite(path, handle); if (visibility) throw visibility; - const diff = createEditUnifiedDiff(path, content, source.content, source); - return { - kind: 'edit', - ok: true, - path, - replacements: 1, - matchedVia: source.matchedVia, - startLine: source.startLine, - endLine: source.endLine, - ...(diff !== undefined ? { diff } : {}), - }; + return buildFileEditResult(path, content, source); } finally { await handle.close(); } diff --git a/packages/runtime/src/filesystem-worker/protocol.ts b/packages/runtime/src/filesystem-worker/protocol.ts index abbb1d2c7e0..317592f6dae 100644 --- a/packages/runtime/src/filesystem-worker/protocol.ts +++ b/packages/runtime/src/filesystem-worker/protocol.ts @@ -18,12 +18,15 @@ */ import { z } from 'zod'; -import { isFileWriteObservation, type FileWriteObservation } from '@maka/core/file-write-evidence'; +import { + isFileMutationObservation, + type FileMutationObservation, +} from '@maka/core/file-mutation-evidence'; import { readContinuationSchema, readPageSchema } from '../read-page.js'; import { validateSandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; import { GREP_MAX_LINES, GREP_MAX_LINES_PER_FILE, GREP_MAX_MATCH_BYTES } from '../grep-search.js'; -// v12 adds non-mutating Write preparation and the guarded execution observation. +// v12 adds non-mutating Write/Edit preparation and the guarded execution observation. // Older workers must not silently discard the guard on a prepared Write. export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 12 as const; @@ -33,6 +36,7 @@ export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 12 as const; export function operationAccess(kind: FilesystemWorkerOperation['kind']): 'read' | 'write' { return kind === 'write' || kind === 'prepare_write' || + kind === 'prepare_edit' || kind === 'apply_patch' || kind === 'edit' || kind === 'format_json' @@ -122,10 +126,19 @@ export const FilesystemWorkerOperationSchema = z.union([ cwd, path, content: z.string(), - observation: z.custom(isFileWriteObservation).optional(), + observation: z.custom(isFileMutationObservation).optional(), }) .strict(), z.object({ kind: z.literal('prepare_write'), cwd, path, content: z.string() }).strict(), + z + .object({ + kind: z.literal('prepare_edit'), + cwd, + path, + oldString: z.string(), + newString: z.string(), + }) + .strict(), z .object({ kind: z.literal('apply_patch'), @@ -143,6 +156,7 @@ export const FilesystemWorkerOperationSchema = z.union([ path, oldString: z.string(), newString: z.string(), + observation: z.custom(isFileMutationObservation).optional(), }) .strict(), z @@ -193,8 +207,8 @@ export const FilesystemWorkerRequestSchema = z export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ z .object({ - kind: z.literal('prepare_write'), - observation: z.custom(isFileWriteObservation).optional(), + kind: z.enum(['prepare_write', 'prepare_edit']), + observation: z.custom(isFileMutationObservation).optional(), }) .strict(), readPageSchema.extend({ kind: z.literal('read') }).strict(), diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 1dc727addfa..5b040afac37 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -130,7 +130,10 @@ import { type RuntimeUserQuestionClosureReason, } from './interaction-authority.js'; -import { bindFileWriteEvidence, type FileWriteObservation } from '@maka/core/file-write-evidence'; +import { + bindFileMutationEvidence, + type FileMutationObservation, +} from '@maka/core/file-mutation-evidence'; export interface ResolvedMakaToolCall { tool: MakaTool; @@ -170,7 +173,7 @@ export type MakaToolPreparationContext = Pick< >; export interface PreparedMakaToolExecution { - fileWriteObservation?: FileWriteObservation; + fileMutationObservation?: FileMutationObservation; execute(context: MakaToolContext): Promise | R; cancel(): Promise | void; } @@ -1508,7 +1511,7 @@ export class ToolRuntime { try { durableAttempt = await this.prepareDurableToolAttempt({ tool, - fileWriteObservation: preparedExecution?.fileWriteObservation, + fileMutationObservation: preparedExecution?.fileMutationObservation, startEvent: buildCallEvent('dispatch'), persistedArgs, abortSignal: ctx.abortSignal, @@ -1929,7 +1932,7 @@ export class ToolRuntime { private async prepareDurableToolAttempt(input: { tool: MakaTool; - fileWriteObservation?: FileWriteObservation; + fileMutationObservation?: FileMutationObservation; startEvent: ToolStartEvent; persistedArgs: unknown; abortSignal: AbortSignal; @@ -2018,9 +2021,9 @@ export class ToolRuntime { toolName: input.tool.name, canonicalArgsHash, recoveryMode, - ...(input.fileWriteObservation + ...(input.fileMutationObservation ? { - fileWriteEvidence: bindFileWriteEvidence(input.fileWriteObservation, { + fileMutationEvidence: bindFileMutationEvidence(input.fileMutationObservation, { sessionId: this.input.sessionId, invocationId, runId, diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 7f98fabeb27..f132ac2ecdc 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -19,8 +19,8 @@ import { promises as fs } from 'node:fs'; import { exec } from 'node:child_process'; -import type { FileWriteObservation } from '@maka/core/file-write-evidence'; -import { prepareFileWrite, executePreparedFileWrite } from './file-write-evidence.js'; +import type { FileMutationObservation, FileMutation } from '@maka/core/file-mutation-evidence'; +import { prepareFileMutation, executePreparedFileMutation } from './file-mutation-evidence.js'; import { globFiles } from './glob-search.js'; import { isAbsolute, resolve } from 'node:path'; import { @@ -167,20 +167,20 @@ export interface WorkspaceReadModifyWriteExecutor { readModifyWrite(input: WorkspaceReadModifyWriteInput): Promise; } -export interface WorkspacePreparedWriteExecutor { - prepareWrite(input: { +export interface WorkspacePreparedMutationExecutor { + prepareMutation(input: { cwd: string; path: string; - content: string; + mutation: FileMutation; scope: WorkspacePathScope; - }): Promise; - executePreparedWrite(input: { + }): Promise; + executePreparedMutation(input: { cwd: string; path: string; - content: string; + mutation: FileMutation; scope: WorkspacePathScope; - observation: FileWriteObservation; - }): Promise; + observation: FileMutationObservation; + }): Promise; } /** @@ -369,22 +369,22 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { }; } - async prepareWrite( - input: Parameters[0], - ): Promise { + async prepareMutation( + input: Parameters[0], + ): Promise { const cwd = await fs.realpath(input.cwd); const { path } = await canonicalPathInScope(cwd, input.path, 'Write', input.scope); // Match the local executor's binary/image result policy. if (isSupportedImagePath(path)) return undefined; - return prepareFileWrite(cwd, path, input.content); + return prepareFileMutation(cwd, path, input.mutation); } - async executePreparedWrite( - input: Parameters[0], - ): Promise { + async executePreparedMutation( + input: Parameters[0], + ): Promise { const cwd = await fs.realpath(input.cwd); const { path } = await canonicalPathInScope(cwd, input.path, 'Write', input.scope); - return executePreparedFileWrite(cwd, path, input.content, input.observation); + return executePreparedFileMutation(cwd, path, input.mutation, input.observation); } async readModifyWrite( From 04d2b7d6c3f324e43f25dca3466ab6fa87f07375 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Tue, 13 Oct 2026 20:10:11 +0800 Subject: [PATCH 03/10] fix(ci): include file mutation evidence in Windows filters Keep package and recovery triggers aligned with their source closures. Generated-by: OpenAI Codex --- .github/workflows/release-windows-check.yml | 3 +++ .github/workflows/windows-recovery.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index 7d1348c7936..32615a91397 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -83,6 +83,7 @@ on: - 'packages/runtime/src/child-fd-input.ts' - 'packages/runtime/src/child-process-lifecycle.ts' - 'packages/runtime/src/edit-replace.ts' + - 'packages/runtime/src/file-mutation-evidence.ts' - 'packages/runtime/src/file-stable-write.ts' - 'packages/runtime/src/image-file.ts' - 'packages/runtime/src/process-tree-terminator.ts' @@ -91,10 +92,12 @@ on: - 'packages/core/src/absolute-path.ts' - 'packages/core/src/artifacts.ts' - 'packages/core/src/attachments.ts' + - 'packages/core/src/file-mutation-evidence.ts' - 'packages/core/src/permission-profile.ts' - 'packages/core/src/permission-profile-compiler.ts' - 'packages/core/src/sandbox-boundary.ts' - 'packages/core/src/serialized-byte-length.ts' + - 'packages/core/src/tool-args-identity.ts' - 'packages/core/src/windows-path.ts' - 'experiments/windows-sandbox/launcher/**' - 'experiments/windows-sandbox/*.ps1' diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index ce46dd16036..9110bd2b055 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -100,6 +100,7 @@ on: - 'packages/runtime/src/__tests__/runtime-resume-crash.test.ts' - 'packages/runtime/src/__tests__/shell-owner-death.test.ts' - 'packages/runtime/src/builtin-tools.ts' + - 'packages/runtime/src/file-mutation-evidence.ts' - 'packages/runtime/src/file-stable-write.ts' - 'packages/runtime/src/filesystem-worker/client.ts' - 'packages/runtime/src/filesystem-worker/launch-spec.ts' From d861a9b706b14c88193fb16597aef89d16d1bd3d Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Tue, 13 Oct 2026 21:38:15 +0800 Subject: [PATCH 04/10] fix(runtime): preserve prepared execution cleanup ownership Cancel only preparations that never started execution. Once execute takes ownership, leave success and failure cleanup to the prepared executor. Preserve the existing Client Capability success contract and cover failures before execution and inside execution. Generated-by: OpenAI Codex --- .../__tests__/tool-runtime-settlement.test.ts | 66 +++++++++++++++++++ packages/runtime/src/tool-runtime.ts | 13 ++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 0633090b8ca..9ff3d69e8b9 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -188,6 +188,72 @@ describe('ToolRuntime settlement', () => { assert.deepEqual(settlement.result, { ok: true }); }); + for (const failureAt of ['publication', 'execution'] as const) { + it(`keeps prepared cleanup ownership when ${failureAt} fails after T1`, async () => { + const order: string[] = []; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + hostAdmission: 'client_capability', + prepareExecution: async () => { + order.push('prepare'); + return { + execute: () => { + order.push('execute'); + try { + throw new Error('execution failed'); + } finally { + order.push('release'); + } + }, + cancel: () => { + order.push('cancel'); + }, + }; + }, + impl: () => assert.fail('Prepared work must not use the direct implementation'), + }; + const runtime = makeRuntime({ + readExecutionBoundary: async () => createBypassExecutionBoundary(0), + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: { + commitToolPrepared: async () => { + order.push('T1'); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }), + }, + }); + const settlement = await runtime.settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: `call-${failureAt}-failure`, + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => { + if (failureAt === 'publication' && event.type === 'tool_start') + throw new Error('publication failed'); + }, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + assert.deepEqual( + order, + failureAt === 'publication' + ? ['prepare', 'T1', 'cancel'] + : ['prepare', 'T1', 'execute', 'release'], + ); + assert.match( + String((settlement.result as { error?: unknown }).error), + new RegExp(`${failureAt} failed`, 'u'), + ); + }); + } + it('cancels prepared Client Capability work when T1 fails', async () => { const order: string[] = []; const clientTool: MakaTool = { diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 5b040afac37..fee842a5822 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -1645,10 +1645,15 @@ export class ToolRuntime { queue, ), }; - const invokeTool = () => - preparedExecution - ? preparedExecution.execute(toolContext) - : tool.impl(structuredClone(executionArgs) as never, toolContext); + const invokeTool = () => { + if (!preparedExecution) + return tool.impl(structuredClone(executionArgs) as never, toolContext); + // Execution takes ownership of cleanup, including when it throws. + // Only a preparation that never starts may be cancelled below. + const execution = preparedExecution; + preparedExecution = undefined; + return execution.execute(toolContext); + }; const prepareOperationValue = async () => { const result = await invokeTool(); if ( From a0f156ff9b3a65dfeec33f30df955c1c3515d0bb Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Tue, 13 Oct 2026 23:03:44 +0800 Subject: [PATCH 05/10] chore(ci): rerun validation for Write/Edit evidence Retry validation after the isolated CodeMode timing test failure; no source changes. Generated-by: OpenAI Codex From f6e160dc152b3aeca0adc30bf3546061b8f4aa38 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Fri, 16 Oct 2026 17:18:10 +0800 Subject: [PATCH 06/10] fix(ci): record upstream marked in artifact ledger The upstream chat-image parser requires marked@18.0.11 in production. Record the single package instance reported by Linux CI without changing the dependency-closure guard. Generated-by: OpenAI Codex --- apps/desktop/artifact-budget.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/artifact-budget.json b/apps/desktop/artifact-budget.json index fc70f8ca4b0..9d279a6508a 100644 --- a/apps/desktop/artifact-budget.json +++ b/apps/desktop/artifact-budget.json @@ -162,6 +162,7 @@ "node_modules/lodash.merge@4.6.2", "node_modules/lodash.pickby@4.6.0", "node_modules/long@5.3.2", + "node_modules/marked@18.0.11", "node_modules/math-intrinsics@1.1.0", "node_modules/minimatch@10.2.5", "node_modules/minipass@7.1.3", From 2b3eff5a3931289f128c3e7555fdc45cea3f48d8 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Fri, 16 Oct 2026 17:30:03 +0800 Subject: [PATCH 07/10] fix(ci): synchronize upstream AppShell token ledger Upstream simplified the navigation-state initializer but left its nonTriviaTokens count stale. Record the measured 7280 tokens so strict base checks reflect the unchanged source tree. Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 16d40288db0..f3a9c66c71b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -743,7 +743,7 @@ "react": 1 }, "importSpecifiers": 43, - "nonTriviaTokens": 7285 + "nonTriviaTokens": 7280 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 3, From 50565abd8fe0c5de4cf0f578abe266e9600a7ced Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Sat, 17 Oct 2026 20:30:38 +0800 Subject: [PATCH 08/10] test(runtime-host): wait for event observation I/O by deadline Replace the fixed immediate-turn budget with bounded wall-clock polling while keeping the injected observation clock unchanged. Add a delayed-work regression that fails with the old helper. Validation: all 251 event-wait tests passed; the two CI failures passed 10 independent runs; Host build/typecheck, Biome, ASF and independent review passed. Generated-by: OpenAI Codex --- .../src/__tests__/event-wait-manager.test.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/event-wait-manager.test.ts b/packages/runtime-host/src/__tests__/event-wait-manager.test.ts index a420ccff8dc..57c3daa0122 100644 --- a/packages/runtime-host/src/__tests__/event-wait-manager.test.ts +++ b/packages/runtime-host/src/__tests__/event-wait-manager.test.ts @@ -22,6 +22,7 @@ import { mkdtemp, rm, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test, type TestContext } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; import { EVENT_WAIT_LIMITS, eventWaitDeliveryKey, @@ -55,9 +56,12 @@ async function settle() { for (let n = 0; n < 40; n++) await new Promise((resolve) => setImmediate(resolve)); } async function eventually(check: () => Promise | boolean) { - for (let n = 0; n < 100; n++) { + // Host I/O needs wall-clock time; immediate turns can exhaust before it completes. + // Keep the injected observation clock fixed so timeout/Retry-After assertions remain exact. + const deadline = performance.now() + 5_000; + while (performance.now() < deadline) { if (await check()) return; - await settle(); + await delay(5); } assert.fail('Condition did not settle without advancing the observation clock'); } @@ -94,6 +98,26 @@ class Clock implements EventWaitClock { await settle(); } } +test('eventually waits for delayed host work without advancing the observation clock', async () => { + const clock = new Clock(); + let observationTimerRan = false; + clock.schedule(() => { + observationTimerRan = true; + }, 1); + let completed = false; + const work = delay(200).then(() => { + completed = true; + }); + try { + await eventually(() => completed); + assert.equal(clock.now(), 1_000); + assert.equal(observationTimerRan, false); + assert.equal(clock.timers.size, 1); + } finally { + await work; + } +}); + const satisfied = (key = 'event-1'): EventWaitObservationResult => ({ kind: 'satisfied', receiptKey: key, From c28bda56067248b2e8e7866804464bce16792ae4 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Sat, 17 Oct 2026 21:18:47 +0800 Subject: [PATCH 09/10] chore: move event-wait CI fix to #6140 Remove the event-wait test-only change from the Write/Edit evidence PR now that it is proposed independently in #6140. Generated-by: OpenAI Codex --- .../src/__tests__/event-wait-manager.test.ts | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/runtime-host/src/__tests__/event-wait-manager.test.ts b/packages/runtime-host/src/__tests__/event-wait-manager.test.ts index 57c3daa0122..a420ccff8dc 100644 --- a/packages/runtime-host/src/__tests__/event-wait-manager.test.ts +++ b/packages/runtime-host/src/__tests__/event-wait-manager.test.ts @@ -22,7 +22,6 @@ import { mkdtemp, rm, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test, type TestContext } from 'node:test'; -import { setTimeout as delay } from 'node:timers/promises'; import { EVENT_WAIT_LIMITS, eventWaitDeliveryKey, @@ -56,12 +55,9 @@ async function settle() { for (let n = 0; n < 40; n++) await new Promise((resolve) => setImmediate(resolve)); } async function eventually(check: () => Promise | boolean) { - // Host I/O needs wall-clock time; immediate turns can exhaust before it completes. - // Keep the injected observation clock fixed so timeout/Retry-After assertions remain exact. - const deadline = performance.now() + 5_000; - while (performance.now() < deadline) { + for (let n = 0; n < 100; n++) { if (await check()) return; - await delay(5); + await settle(); } assert.fail('Condition did not settle without advancing the observation clock'); } @@ -98,26 +94,6 @@ class Clock implements EventWaitClock { await settle(); } } -test('eventually waits for delayed host work without advancing the observation clock', async () => { - const clock = new Clock(); - let observationTimerRan = false; - clock.schedule(() => { - observationTimerRan = true; - }, 1); - let completed = false; - const work = delay(200).then(() => { - completed = true; - }); - try { - await eventually(() => completed); - assert.equal(clock.now(), 1_000); - assert.equal(observationTimerRan, false); - assert.equal(clock.timers.size, 1); - } finally { - await work; - } -}); - const satisfied = (key = 'event-1'): EventWaitObservationResult => ({ kind: 'satisfied', receiptKey: key, From 7b801b4680e58688f944ea7f16629837b20b4192 Mon Sep 17 00:00:00 2001 From: Yang Wu <1425326620@qq.com> Date: Thu, 22 Oct 2026 16:35:54 +0800 Subject: [PATCH 10/10] perf(runtime): trim Write/Edit preparation payloads Compute input eligibility from private arguments before file observation and send only preparation metadata through the v15 worker protocol. Keep permission checks for ineligible inputs and preserve the two-process execution lifecycle. Hide the prepared guard interface, remove redundant Write wrappers and avoid copying freshly created results. Cover byte limits, fallback, private snapshot isolation, permissions and wire compatibility. Generated-by: OpenAI Codex --- docs/architecture/file-mutation-guards.md | 4 +- .../src/__tests__/file-mutation-guard.test.ts | 151 +++++++++++++++--- .../filesystem-worker-client.test.ts | 68 +++++--- .../src/file-mutation-guard-contract.ts | 5 + packages/runtime/src/file-mutation-guard.ts | 52 +++--- packages/runtime/src/filesystem-executor.ts | 27 ++-- .../src/filesystem-worker/operations.ts | 12 +- .../runtime/src/filesystem-worker/protocol.ts | 9 +- packages/runtime/src/workspace-executor.ts | 5 +- 9 files changed, 219 insertions(+), 114 deletions(-) diff --git a/docs/architecture/file-mutation-guards.md b/docs/architecture/file-mutation-guards.md index a04a50aecd7..96ea1c46991 100644 --- a/docs/architecture/file-mutation-guards.md +++ b/docs/architecture/file-mutation-guards.md @@ -23,9 +23,9 @@ Write/Edit calls using the durable ToolRuntime preparation path can reject a tar ## Execution boundary -The filesystem executor holds the existing per-target lock across read-only preparation, the ordinary T1 commit and execution. Preparation records only the canonical workspace/target, directory identities, target identity and bounded pre-state byte count/hash (or a missing target). It does not predict a successful result, compute an expected-after hash, or persist recovery evidence. Runtime owns only the prepared executor's execute/cancel lifecycle; T1/T2 retain their upstream schemas and recovery policy. +The filesystem executor holds the existing per-target lock across read-only preparation, the ordinary T1 commit and execution. Input eligibility is computed from the private argument snapshot before preparation. Preparation requests carry the operation kind, target and eligibility flag, without replacement text. Ineligible inputs still pass the preparation permission checks but skip content observation. Preparation records only the canonical workspace/target, directory identities, target identity and bounded pre-state byte count/hash (or a missing target). It does not predict a successful result, compute an expected-after hash, or persist recovery evidence. Runtime owns only the prepared executor's execute/cancel lifecycle; T1/T2 retain their upstream schemas and recovery policy. -Execution checks the same directory identities and the descriptor it will mutate before truncation. A changed identity, content length or hash rejects the write. Missing Write targets use exclusive creation. Edit matching and result/diff construction happen once, during execution. The private argument snapshot cannot be changed through the caller or a returned guard copy. The filesystem worker protocol rejects older versions rather than silently ignoring guards. +Execution checks the same directory identities and the descriptor it will mutate before truncation. A changed identity, content length or hash rejects the write. Missing Write targets use exclusive creation. Edit matching and result/diff construction happen once, during execution. The guard remains private to the prepared executor, whose callers receive only execute/cancel. The private argument snapshot cannot be changed through the caller. The filesystem worker protocol rejects older versions rather than silently ignoring guards. ## Coverage and limits diff --git a/packages/runtime/src/__tests__/file-mutation-guard.test.ts b/packages/runtime/src/__tests__/file-mutation-guard.test.ts index 2fd3e868bfc..dda940e83ae 100644 --- a/packages/runtime/src/__tests__/file-mutation-guard.test.ts +++ b/packages/runtime/src/__tests__/file-mutation-guard.test.ts @@ -27,13 +27,18 @@ import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import type { RuntimeCommitSink, ToolPreparedCommit } from '../runtime-commit-sink.js'; import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; import { buildBuiltinTools } from '../builtin-tools.js'; -import { createLocalWorkspaceExecutor } from '../workspace-executor.js'; +import { createLocalWorkspaceExecutor, LocalWorkspaceExecutor } from '../workspace-executor.js'; import { createBoundaryFilesystemExecutor } from '../filesystem-executor.js'; import { FILE_MUTATION_MAX_BYTES, FileMutationGuardSchema, + type FileMutationGuard, } from '../file-mutation-guard-contract.js'; -import { prepareFileWrite, executePreparedFileMutation } from '../file-mutation-guard.js'; +import { + prepareFileMutation, + fileMutationInputEligible, + executePreparedFileMutation, +} from '../file-mutation-guard.js'; import { runFilesystemWorkerProcess } from '../filesystem-worker/process-runner.js'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION, @@ -180,7 +185,6 @@ test('prepared and ordinary local Write retain identical result shapes', async ( call.operation.path = join(f.cwd, 'image.png'); await writeFile(call.operation.path, 'text with an image extension'); const prepared = await f.filesystem.prepareMutation(call); - assert.equal(prepared.guard, undefined); const result = await prepared.execute(); assert.equal(result.kind === 'write' && result.diff, undefined); }); @@ -204,7 +208,6 @@ test('prepared Edit keeps exact and fuzzy production results and rejects ambiguo }; await writeFile(f.path, source!); const prepared = await f.filesystem.prepareMutation(call); - assert.ok(prepared.guard); assert.equal(await readFile(f.path, 'utf8'), source); const result = await prepared.execute(); assert.equal(result.kind, 'edit'); @@ -216,7 +219,6 @@ test('prepared Edit keeps exact and fuzzy production results and rejects ambiguo } await writeFile(f.path, 'before before'); const ambiguous = await f.filesystem.prepareMutation(f.call()); - assert.ok(ambiguous.guard); await assert.rejects(ambiguous.execute(), /not unique/); assert.equal(await readFile(f.path, 'utf8'), 'before before'); }); @@ -240,16 +242,67 @@ test('Edit cancellation, identity substitution and duplicate execution preserve assert.equal(await readFile(f.path, 'utf8'), 'outside'); }); +test('input eligibility preserves UTF-8 byte limits for both mutation kinds', () => { + const boundary = 'é'.repeat(FILE_MUTATION_MAX_BYTES / 2); + for (const [content, eligible] of [ + [boundary, true], + [boundary + 'x', false], + ['\ud800', false], + ] as const) { + assert.equal(fileMutationInputEligible({ kind: 'write', content }), eligible); + assert.equal( + fileMutationInputEligible({ kind: 'edit', oldString: content, newString: 'after' }), + eligible, + ); + assert.equal( + fileMutationInputEligible({ kind: 'edit', oldString: 'before', newString: content }), + eligible, + ); + } +}); + +for (const kind of ['write', 'edit'] as const) { + test(`${kind}: ineligible input keeps ordinary execution after content changes`, async (t) => { + const f = await fixture(t, kind); + await writeFile(f.path, 'before\n'); + const content = 'x'.repeat(FILE_MUTATION_MAX_BYTES + 1); + const call = { + ...f.call(), + operation: + kind === 'write' + ? { kind, path: f.path, content } + : { kind, path: f.path, oldString: 'before', newString: content }, + }; + const prepared = await f.filesystem.prepareMutation(call); + await writeFile(f.path, 'before external\n'); + const result = await prepared.execute(); + assert.equal( + await readFile(f.path, 'utf8'), + kind === 'write' ? content : content + ' external\n', + ); + await writeFile(f.path, 'before external\n'); + assert.deepEqual(await f.filesystem.execute(call), result); + }); +} + test('invalid UTF-8, oversized content, and unsupported executor guards stay conservative', async (t) => { const f = await fixture(t); await writeFile(f.path, Buffer.from([0xff, 0xfe])); - assert.equal(await prepareFileWrite(f.cwd, f.path, 'after'), undefined); assert.equal( - await prepareFileWrite(f.cwd, f.path, 'a'.repeat(FILE_MUTATION_MAX_BYTES + 1)), + await prepareFileMutation(f.cwd, f.path, { kind: 'write', eligible: true }), + undefined, + ); + assert.equal( + await prepareFileMutation(f.cwd, f.path, { + kind: 'write', + eligible: fileMutationInputEligible({ + kind: 'write', + content: 'a'.repeat(FILE_MUTATION_MAX_BYTES + 1), + }), + }), undefined, ); const prepared = await f.filesystem.prepareMutation(f.call()); - assert.equal(prepared.guard, undefined); await prepared.execute(); assert.equal(await readFile(f.path, 'utf8'), 'after\n'); const unsupported = createBoundaryFilesystemExecutor({ @@ -259,7 +312,6 @@ test('invalid UTF-8, oversized content, and unsupported executor guards stay con }), }); const fallback = await unsupported.prepareMutation(f.call()); - assert.equal(fallback.guard, undefined); await fallback.execute(); }); @@ -334,17 +386,34 @@ for (const kind of ['write', 'edit'] as const) { }); } -test('caller arguments and returned guard cannot mutate the private prepared execution', async (t) => { - const f = await fixture(t); - await writeFile(f.path, 'before\n'); - const call = f.call(); - const prepared = await f.filesystem.prepareMutation(call); - assert.ok(prepared.guard); - if (call.operation.kind === 'write') call.operation.content = 'wrong'; - prepared.guard.target.path = join(f.cwd, 'wrong.txt'); - await prepared.execute(); - assert.equal(await readFile(f.path, 'utf8'), 'after\n'); -}); +for (const kind of ['write', 'edit'] as const) { + test(`${kind}: prepared execution hides its guard and isolates caller/backend objects`, async (t) => { + const f = await fixture(t, kind); + await writeFile(f.path, 'before\n'); + const workspace = new LocalWorkspaceExecutor(); + const prepare = workspace.prepareMutation.bind(workspace); + let backendGuard: FileMutationGuard | undefined; + const filesystem = createBoundaryFilesystemExecutor({ + workspace: Object.assign(workspace, { + prepareMutation: async (...args: Parameters) => { + backendGuard = await prepare(...args); + return backendGuard; + }, + }), + }); + const call = f.call(); + const prepared = await filesystem.prepareMutation(call); + assert.deepEqual(Object.keys(prepared).sort(), ['cancel', 'execute']); + assert.ok(backendGuard); + backendGuard.target.path = join(f.cwd, 'wrong.txt'); + call.operation.path = join(f.cwd, 'wrong.txt'); + if (call.operation.kind === 'write') call.operation.content = 'wrong'; + else call.operation.newString = 'wrong'; + await prepared.execute(); + assert.equal(await readFile(f.path, 'utf8'), 'after\n'); + await assert.rejects(readFile(join(f.cwd, 'wrong.txt')), { code: 'ENOENT' }); + }); +} test('parent replacement is rejected without touching the replacement file', async (t) => { const f = await fixture(t); @@ -366,7 +435,7 @@ test('parent replacement is rejected without touching the replacement file', asy test('guard shape rejects legacy evidence and cross-tool reuse before mutation', async (t) => { const f = await fixture(t); - const guard = await prepareFileWrite(f.cwd, f.path, 'after'); + const guard = await prepareFileMutation(f.cwd, f.path, { kind: 'write', eligible: true }); assert.ok(guard); assert.equal( FileMutationGuardSchema.safeParse({ ...guard, after: { bytes: 5, digest: 'unused' } }).success, @@ -421,10 +490,38 @@ for (const kind of ['write', 'edit'] as const) { assert.equal(result.exitCode, 0, result.stderrTail); return parseFilesystemWorkerResponse(JSON.parse(result.stdout)); }; - const prepared = await run({ + const preparationOperation = { + kind: kind === 'write' ? 'prepare_write' : 'prepare_edit', + path: f.path, + cwd: f.cwd, + eligible: true, + }; + const declined = await run({ ...request, - operation: { ...operation, kind: kind === 'write' ? 'prepare_write' : 'prepare_edit' }, + operation: { ...preparationOperation, eligible: false }, }); + assert.ok( + declined.ok && + (declined.result.kind === 'prepare_write' || declined.result.kind === 'prepare_edit'), + ); + assert.equal(declined.result.guard, undefined); + const deniedCwd = join(f.cwd, 'restricted'); + await mkdir(deniedCwd); + const denied = await run({ + ...request, + operation: { ...preparationOperation, cwd: deniedCwd, eligible: false }, + operationBoundary: { + filesystem: { entries: [{ path: f.path, access: 'read', scope: 'exact' }] }, + }, + }); + assert.ok(!denied.ok && denied.error.code === 'path_denied'); + const payloadRejected = await run({ + ...request, + operation: { ...operation, ...preparationOperation }, + }); + assert.ok(!payloadRejected.ok && payloadRejected.error.code === 'invalid_request'); + assert.equal(await readFile(f.path, 'utf8'), 'before\n'); + const prepared = await run({ ...request, operation: preparationOperation }); assert.ok( prepared.ok && (prepared.result.kind === 'prepare_write' || prepared.result.kind === 'prepare_edit') && @@ -434,8 +531,10 @@ for (const kind of ['write', 'edit'] as const) { assert.equal(await readFile(f.path, 'utf8'), 'before\n'); assert.equal('result' in guard, false); assert.equal('after' in guard, false); - const legacy = await run({ ...request, version: 13, operation: { ...operation, guard } }); - assert.equal(legacy.ok, false); + for (const version of [12, 13, 14]) { + const legacy = await run({ ...request, version, operation: { ...operation, guard } }); + assert.equal(legacy.ok, false); + } assert.equal(await readFile(f.path, 'utf8'), 'before\n'); await writeFile(f.path, 'outside\n'); const rejected = await run({ ...request, operation: { ...operation, guard } }); @@ -454,7 +553,7 @@ for (const kind of ['write', 'edit'] as const) { await writeFile(f.path, content); const preparation = await run({ ...request, - operation: { ...operation, kind: 'prepare_edit' }, + operation: { kind: 'prepare_edit', path: f.path, cwd: f.cwd, eligible: true }, }); assert.ok(preparation.ok && preparation.result.kind === 'prepare_edit'); assert.ok(preparation.result.guard); diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index d251b860b95..fdeb4740ab2 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -36,7 +36,7 @@ import { afterEach, describe, test } from 'node:test'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; -import { prepareFileWrite } from '../file-mutation-guard.js'; +import { prepareFileMutation } from '../file-mutation-guard.js'; import { executeFilesystemWorkerRequest } from '../filesystem-worker/operations.js'; import { canReadPath, @@ -87,13 +87,22 @@ describe('filesystem worker client permission snapshots', () => { const { client, requests } = fakeClient(); for (const kind of ['prepare_write', 'write'] as const) { await client.execute({ - operation: { kind, path: 'notes.txt', content: 'after' }, + operation: + kind === 'prepare_write' + ? { kind, path: 'notes.txt', eligible: true } + : { kind, path: 'notes.txt', content: 'after' }, cwd: workspace, mode: 'ask', expectedIdentity: 'missing', }); } assert.equal(requests[0]!.operation.kind, 'prepare_write'); + assert.deepEqual(Object.keys(requests[0]!.operation).sort(), [ + 'cwd', + 'eligible', + 'kind', + 'path', + ]); assert.deepEqual(requests[0]!.operationBoundary, requests[1]!.operationBoundary); assert.deepEqual(requests[0]!.expectedTarget, requests[1]!.expectedTarget); assert.equal(requests[0]!.expectedTarget.access, 'write'); @@ -109,40 +118,51 @@ describe('filesystem worker client permission snapshots', () => { const { client, requests } = fakeClient(); for (const kind of ['prepare_edit', 'edit'] as const) { await client.execute({ - operation: { kind, path, oldString: 'before', newString: 'after' }, + operation: + kind === 'prepare_edit' + ? { kind, path, eligible: true } + : { kind, path, oldString: 'before', newString: 'after' }, cwd, mode: 'ask', expectedIdentity: { dev: String(stat.dev), ino: String(stat.ino) }, }); } + assert.deepEqual(Object.keys(requests[0]!.operation).sort(), [ + 'cwd', + 'eligible', + 'kind', + 'path', + ]); assert.deepEqual(requests[0]!.operationBoundary, requests[1]!.operationBoundary); assert.deepEqual(requests[0]!.expectedTarget, requests[1]!.expectedTarget); assert.equal(requests[0]!.expectedTarget.access, 'write'); assert.equal(requests[0]!.hostDirectories, undefined); }); - test('a read-only profile cannot prepare an Edit', async () => { - const cwd = await temporaryDirectory('maka-edit-prepare-denied-'); - const path = join(cwd, 'notes.txt'); - await writeFile(path, 'before'); - const { client, requests } = fakeClient(); - await assert.rejects( - client.execute({ - operation: { kind: 'prepare_edit', path, oldString: 'before', newString: 'after' }, - cwd, - permissionProfile: createReadOnlyPermissionProfile(), - expectedIdentity: 'unchecked', - }), - (error: unknown) => - error instanceof FilesystemWorkerClientError && error.reason === 'path_denied', - ); - assert.deepEqual(requests, []); - }); + for (const eligible of [true, false]) { + test(`a read-only profile cannot prepare an Edit (eligible=${eligible})`, async () => { + const cwd = await temporaryDirectory('maka-edit-prepare-denied-'); + const path = join(cwd, 'notes.txt'); + await writeFile(path, 'before'); + const { client, requests } = fakeClient(); + await assert.rejects( + client.execute({ + operation: { kind: 'prepare_edit', path, eligible }, + cwd, + permissionProfile: createReadOnlyPermissionProfile(), + expectedIdentity: 'unchecked', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && error.reason === 'path_denied', + ); + assert.deepEqual(requests, []); + }); + } test('a prepared Write rejects substituted host directory identity before worker launch', async () => { const workspace = await temporaryDirectory('maka-worker-prepare-host-'); const path = join(workspace, 'notes.txt'); - const guard = await prepareFileWrite(workspace, path, 'after'); + const guard = await prepareFileMutation(workspace, path, { kind: 'write', eligible: true }); assert.ok(guard); guard.workspace.identity.ino += '1'; const { client, requests } = fakeClient(); @@ -166,7 +186,7 @@ describe('filesystem worker client permission snapshots', () => { const previous = join(cwd, 'previous'); const path = join(parent, 'target.txt'); await mkdir(parent); - const guard = await prepareFileWrite(cwd, path, 'after'); + const guard = await prepareFileMutation(cwd, path, { kind: 'write', eligible: true }); assert.ok(guard); const swap = async () => { await rename(parent, previous); @@ -238,7 +258,7 @@ describe('filesystem worker client permission snapshots', () => { const parent = join(cwd, 'parent'); await mkdir(parent); const path = join(parent, 'target.txt'); - const guard = await prepareFileWrite(cwd, path, 'after'); + const guard = await prepareFileMutation(cwd, path, { kind: 'write', eligible: true }); assert.ok(guard); let transformed = false; const manager = new SandboxManager([]); @@ -290,7 +310,7 @@ describe('filesystem worker client permission snapshots', () => { const { client, requests } = fakeClient(); await assert.rejects( client.execute({ - operation: { kind: 'prepare_write', path: 'notes.txt', content: 'after' }, + operation: { kind: 'prepare_write', path: 'notes.txt', eligible: true }, cwd: workspace, permissionProfile: createReadOnlyPermissionProfile(), expectedIdentity: 'missing', diff --git a/packages/runtime/src/file-mutation-guard-contract.ts b/packages/runtime/src/file-mutation-guard-contract.ts index 43323c04d74..9c0b98114e9 100644 --- a/packages/runtime/src/file-mutation-guard-contract.ts +++ b/packages/runtime/src/file-mutation-guard-contract.ts @@ -54,6 +54,11 @@ export interface FileMutationDirectories { workspace: FileIdentity; parent: FileIdentity; } +/** Eligibility is computed from the owning executor's private argument snapshot. */ +export interface FileMutationPreparation { + kind: 'write' | 'edit'; + eligible: boolean; +} export type FileMutation = | { kind: 'write'; content: string } | { kind: 'edit'; oldString: string; newString: string }; diff --git a/packages/runtime/src/file-mutation-guard.ts b/packages/runtime/src/file-mutation-guard.ts index 62f265882ba..54e6602f8b4 100644 --- a/packages/runtime/src/file-mutation-guard.ts +++ b/packages/runtime/src/file-mutation-guard.ts @@ -25,6 +25,7 @@ import { FILE_MUTATION_MAX_BYTES, FileMutationGuardSchema, type FileMutation, + type FileMutationPreparation, type FileWriteResult, type FileEditResult, type FileIdentity, @@ -133,13 +134,26 @@ function transform(path: string, mutation: FileMutation, previous: string | unde return { content: edited.content, result: buildFileEditResult(path, previous, edited) }; } +/** Pure input eligibility check, before any file observation or content transport. */ +export function fileMutationInputEligible(mutation: FileMutation): boolean { + const inputs = + mutation.kind === 'write' ? [mutation.content] : [mutation.oldString, mutation.newString]; + return inputs.every( + (input) => + Buffer.byteLength(input, 'utf8') <= FILE_MUTATION_MAX_BYTES && + Buffer.from(input, 'utf8').toString('utf8') === input, + ); +} + /** Called only after the owning executor has authorized and canonicalized both paths. */ export async function prepareFileMutation( cwd: string, path: string, - mutation: FileMutation, + preparation: FileMutationPreparation, hostDirectories?: FileMutationDirectories, ): Promise { + // Authorization still runs before this helper, including for ineligible inputs. + if (!preparation.eligible) return; // Unsupported targets retain ordinary execution, without a content guard. try { const workspace = { @@ -183,34 +197,14 @@ export async function prepareFileMutation( await handle.close(); } } - if (mutation.kind === 'edit' && before.kind === 'missing') return; - // Bound request strings too, without computing Edit's future content or result. - const inputs = - mutation.kind === 'write' ? [mutation.content] : [mutation.oldString, mutation.newString]; - if ( - inputs.some( - (input) => - Buffer.byteLength(input, 'utf8') > FILE_MUTATION_MAX_BYTES || - Buffer.from(input, 'utf8').toString('utf8') !== input, - ) - ) - return; - const guard: FileMutationGuard = { kind: mutation.kind, workspace, target, before }; + if (preparation.kind === 'edit' && before.kind === 'missing') return; + const guard: FileMutationGuard = { kind: preparation.kind, workspace, target, before }; return FileMutationGuardSchema.safeParse(guard).success ? guard : undefined; } catch { return undefined; } } -export function prepareFileWrite( - cwd: string, - path: string, - content: string, - directories?: FileMutationDirectories, -) { - return prepareFileMutation(cwd, path, { kind: 'write', content }, directories); -} - /** Validate through the descriptor that will be mutated, before any truncate. */ export async function executePreparedFileMutation( cwd: string, @@ -283,18 +277,8 @@ export async function executePreparedFileMutation( await writeThroughHandle(handle, next.content); const visibility = await hostVisibilityAfterWrite(path, handle); if (visibility) throw visibility; - return structuredClone(next.result); + return next.result; } finally { await handle.close(); } } - -export function executePreparedFileWrite( - cwd: string, - path: string, - content: string, - guard: FileMutationGuard, - directories?: FileMutationDirectories, -) { - return executePreparedFileMutation(cwd, path, { kind: 'write', content }, guard, directories); -} diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 0bd9e31adc3..86ba59f86f4 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -29,8 +29,11 @@ // "full access" stricter than ask mode, which grants :slash_tmp outright (#2083). import { Buffer } from 'node:buffer'; -import { buildFileWriteResult, buildFileEditResult } from './file-mutation-guard.js'; -import type { FileMutationGuard } from './file-mutation-guard-contract.js'; +import { + buildFileWriteResult, + buildFileEditResult, + fileMutationInputEligible, +} from './file-mutation-guard.js'; import { readPage } from './read-page.js'; import { formatJsonText } from './format-json.js'; import { lstat, realpath, stat } from 'node:fs/promises'; @@ -126,7 +129,6 @@ export interface FilesystemExecutor { } export interface PreparedFilesystemMutation { - guard?: FileMutationGuard; execute(): Promise; cancel(): void; } @@ -312,6 +314,7 @@ export function createBoundaryFilesystemExecutor( }; // Keep an immutable private copy: the caller never owns execution args. const operation = structuredClone(call.operation); + const eligible = fileMutationInputEligible(operation); const { key, canonicalPath } = await writeLockTarget(call, operation.path); const expectedIdentity = await captureIdentityAtLockAcquisition(canonicalPath, true); const release = await acquireFileWriteLock(key, call.abortSignal); @@ -329,12 +332,11 @@ export function createBoundaryFilesystemExecutor( ...call, operation: operation.kind === 'write' - ? { kind: 'prepare_write', path: operation.path, content: operation.content } + ? { kind: 'prepare_write', path: operation.path, eligible } : { kind: 'prepare_edit', path: operation.path, - oldString: operation.oldString, - newString: operation.newString, + eligible, }, }, expectedIdentity, @@ -358,7 +360,6 @@ export function createBoundaryFilesystemExecutor( call.abortSignal?.throwIfAborted(); call.abortSignal?.addEventListener('abort', cancel, { once: true }); return { - ...(guard ? { guard: structuredClone(guard) } : {}), cancel, async execute() { if (state !== 'prepared') throw new Error('Prepared Write has already settled'); @@ -486,14 +487,10 @@ function createWorkspaceFilesystemExecutor( ? await workspace.prepareMutation({ cwd, path: operation.path, - mutation: - operation.kind === 'prepare_write' - ? { kind: 'write', content: operation.content } - : { - kind: 'edit', - oldString: operation.oldString, - newString: operation.newString, - }, + preparation: { + kind: operation.kind === 'prepare_write' ? 'write' : 'edit', + eligible: operation.eligible, + }, scope, }) : undefined; diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 02cf1f7256a..16805e38abf 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -23,7 +23,6 @@ import { FileMutationEditConflict, executePreparedFileMutation, buildFileEditResult, - executePreparedFileWrite, buildFileWriteResult, } from '../file-mutation-guard.js'; import { promises as fs } from 'node:fs'; @@ -138,9 +137,10 @@ export async function executeFilesystemOperation( const guard = await prepareFileMutation( operation.cwd, path, - operation.kind === 'prepare_write' - ? { kind: 'write', content: operation.content } - : { kind: 'edit', oldString: operation.oldString, newString: operation.newString }, + { + kind: operation.kind === 'prepare_write' ? 'write' : 'edit', + eligible: operation.eligible, + }, dependencies.hostDirectories, ); return { kind: operation.kind, ...(guard ? { guard } : {}) }; @@ -190,10 +190,10 @@ export async function executeFilesystemOperation( operationBoundary, ); if (operation.guard) { - return executePreparedFileWrite( + return executePreparedFileMutation( operation.cwd, path, - operation.content, + { kind: 'write', content: operation.content }, operation.guard, dependencies.hostDirectories, ); diff --git a/packages/runtime/src/filesystem-worker/protocol.ts b/packages/runtime/src/filesystem-worker/protocol.ts index a1a1f01c083..4659d9033fd 100644 --- a/packages/runtime/src/filesystem-worker/protocol.ts +++ b/packages/runtime/src/filesystem-worker/protocol.ts @@ -23,9 +23,9 @@ import { readContinuationSchema, readPageSchema } from '../read-page.js'; import { validateSandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; import { GREP_MAX_LINES, GREP_MAX_LINES_PER_FILE, GREP_MAX_MATCH_BYTES } from '../grep-search.js'; -// v14 replaces persisted-evidence observations with transient content guards. +// v15 removes mutation content from preparation requests; only input eligibility crosses. // Older workers must be rejected rather than silently discard mutation guards. -export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 14 as const; +export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 15 as const; /** The single authority on which operation kinds are writes. Shared by the * client (permission/identity decisions) and the worker (operation guards) so @@ -127,14 +127,13 @@ export const FilesystemWorkerOperationSchema = z.union([ guard: FileMutationGuardSchema.optional(), }) .strict(), - z.object({ kind: z.literal('prepare_write'), cwd, path, content: z.string() }).strict(), + z.object({ kind: z.literal('prepare_write'), cwd, path, eligible: z.boolean() }).strict(), z .object({ kind: z.literal('prepare_edit'), cwd, path, - oldString: z.string(), - newString: z.string(), + eligible: z.boolean(), }) .strict(), z diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index 065e4a8b7ba..47d66f2a1f8 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -22,6 +22,7 @@ import { exec } from 'node:child_process'; import type { FileMutationGuard, FileMutation, + FileMutationPreparation, FileWriteResult, FileEditResult, } from './file-mutation-guard-contract.js'; @@ -176,7 +177,7 @@ export interface WorkspacePreparedMutationExecutor { prepareMutation(input: { cwd: string; path: string; - mutation: FileMutation; + preparation: FileMutationPreparation; scope: WorkspacePathScope; }): Promise; executePreparedMutation(input: { @@ -381,7 +382,7 @@ export class LocalWorkspaceExecutor implements WorkspaceExecutor { const { path } = await canonicalPathInScope(cwd, input.path, 'Write', input.scope); // Match the local executor's binary/image result policy. if (isSupportedImagePath(path)) return undefined; - return prepareFileMutation(cwd, path, input.mutation); + return prepareFileMutation(cwd, path, input.preparation); } async executePreparedMutation(