From 343beb7dbba7f09bcc1bdb1f1d68a3520a95ad28 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 31 Aug 2026 13:38:16 +0800 Subject: [PATCH] feat(runtime-host): add managed Node workspace transform kernel --- ...ged-coding-v2-product-composition.zh-CN.md | 2 +- .../managed-node-transform-kernel-v1.zh-CN.md | 80 ++++ ...time-durable-coding-m3-m5-roadmap.zh-CN.md | 5 + .../core/src/__tests__/runtime-event.test.ts | 101 +++-- .../workspace-version-authority.test.ts | 6 +- packages/core/src/runtime-event.ts | 154 ++++++-- .../gitoxide-candidate-receipt-crash-child.ts | 4 +- ...-managed-write-edit-owner-internal.test.ts | 4 +- ...itory-admission-authority-internal.test.ts | 12 +- .../hosted-execution-tool-profile.test.ts | 2 +- ...ged-command-sandbox-owner-internal.test.ts | 115 +++++- ...transform-admission-owner-internal.test.ts | 199 ++++++++++ ...gitoxide-managed-session-owner-internal.ts | 46 ++- ...oxide-managed-write-edit-owner-internal.ts | 100 +++-- ...on-candidate-receipt-authority-internal.ts | 10 +- .../server/hosted-execution-tool-profile.ts | 2 +- .../managed-command-sandbox-owner-internal.ts | 179 ++++++++- ...node-transform-admission-owner-internal.ts | 351 ++++++++++++++++++ .../tool-runtime-durable-boundary.test.ts | 150 ++++++-- packages/runtime/src/tool-runtime.ts | 110 ++++-- .../sqlite-recovery-concurrency-child.ts | 2 +- .../__tests__/sqlite-runtime-crash.test.ts | 4 +- ...pace-version-authority-persistence.test.ts | 130 ++++++- packages/storage/src/sqlite-runtime-store.ts | 58 ++- 24 files changed, 1621 insertions(+), 205 deletions(-) create mode 100644 docs/architecture/managed-node-transform-kernel-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/managed-node-transform-admission-owner-internal.test.ts create mode 100644 packages/runtime-host/src/server/managed-node-transform-admission-owner-internal.ts diff --git a/docs/architecture/managed-coding-v2-product-composition.zh-CN.md b/docs/architecture/managed-coding-v2-product-composition.zh-CN.md index 33f8630e3f..99fa4101d8 100644 --- a/docs/architecture/managed-coding-v2-product-composition.zh-CN.md +++ b/docs/architecture/managed-coding-v2-product-composition.zh-CN.md @@ -26,7 +26,7 @@ Read / Glob / Grep / Write / Edit / ManagedNodeTest ``` - Read/Glob/Grep:`replay_safe`,读取 accepted tree; -- Write/Edit:`reconcile + managed_mutation_v1`; +- Write/Edit:`reconcile + managed_mutation_v2`; - ManagedNodeTest:`replay_safe + managed_observation_v2`;dependency 输入只能是显式 `none` 或 owner-bound immutable snapshot lease,禁止从 checkout `node_modules` 回退; - Bash、npm、package script、PATH executable 与 attached checkout 均不在 profile 内。 diff --git a/docs/architecture/managed-node-transform-kernel-v1.zh-CN.md b/docs/architecture/managed-node-transform-kernel-v1.zh-CN.md new file mode 100644 index 0000000000..bf35c2e1fc --- /dev/null +++ b/docs/architecture/managed-node-transform-kernel-v1.zh-CN.md @@ -0,0 +1,80 @@ +# Managed Node workspace transform kernel v1 + +status: enabling infrastructure + +milestone: M5 workspace transform + +## 1. 主要不变量 + +一次 `ManagedNodeTransform` 必须是下面这条纯转换链: + +```text +exact accepted Git tree + exact entry + exact argv + -> sandboxed Node process + -> one owner-selected bounded UTF-8 output + -> immutable Gitoxide candidate + -> atomic SQLite successor acceptance + -> accepted ref projection +``` + +transform 不能直接写 managed worktree、accepted ref、用户 checkout 或任意调用者路径。模型只能选择 accepted +tree 中的显式 Node entry、一个 canonical output path 和有界 argv;物理 output path、cwd、environment、toolchain、 +sandbox、timeout 与 candidate publication 都由 owner 决定。 + +本切片只建立 kernel。packaged manifest/profile v4、Desktop pre-Session negotiation 与真实 Host kill/reopen 属于 +独立产品组合切片;在该切片落地前,生产 Host 不宣告此工具。 + +## 2. Owner 与权限 + +- accepted input owner:Gitoxide managed session; +- execution-root owner:短生命周期 accepted-tree materialization 与 scratch; +- toolchain/process owner:opaque toolchain capability 与 `ManagedCommandSandboxOwnerInternal`; +- transform admission owner:`ManagedNodeTransformOwnerInternal`; +- candidate/projection owner:Gitoxide mutation candidate authority; +- accepted truth owner:SQLite RuntimeEvents 与 workspace successor transaction; +- provider result owner:Runtime 的有界 immutable snapshot。 + +`managed_mutation_v3` 在 T1 前绑定 exact accepted head、workspace epoch、output path、entry bytes/hash、argv、 +toolchain identity 与 execution profile。`managed_mutation_v2` 继续表达 Write/Edit 的参数形状;两种 RuntimeEvent +payload 都只允许同一个 canonical mutation execution profile v2 digest。payload protocol 用于区分 operation +proof 的字段,不再形成两套 execution authority,也没有 v1 digest 兼容路径。 + +## 3. 原子性边界 + +T1 前: + +1. 验证当前 durable epoch/head; +2. materialize exact accepted tree 到 owner root; +3. 观察 entry identity并验证 `workspace_transform_v1` toolchain capability; +4. 冻结 v3 dispatch 与唯一 operation capability; +5. SQLite 在同一 T1 transaction 写 call、dispatch 与 exclusive mutation reservation。 + +T1 后: + +1. Node 只读取 accepted materialization,只能写 scratch 中 owner 指定的 `MAKA_OUTPUT_PATH`; +2. `PATH=''`,child process 禁止,network restricted; +3. root process 完全退出后读取一个普通、非 symlink、最多 1 MiB 的 strict UTF-8 文件; +4. entry 前后 identity、output bytes 与 SHA-256 必须一致; +5. Gitoxide 把 exact output content 固化为 immutable candidate; +6. SQLite 原子提交 T2、successor fact、canonical head,并释放 reservation; +7. accepted ref 是可重建 projection,promotion 失败不得回滚已经接受的 Runtime truth。 + +## 4. 失败状态与收敛 + +- T1 前失败:删除 execution roots,不写 durable mutation; +- T1 后、process dispatch 前后失败:没有充分 terminal evidence 时保持 reservation 并 park;禁止 generic T2; +- transform 返回 no-change:提交 `no_workspace_change` terminal 并释放 reservation; +- candidate 已发布、SQLite 未接受:retry/recovery 重验 exact operation、profile、path 与 candidate receipt; +- SQLite 已接受、ref 未 promotion:只重放 projection,不重跑 Node transform; +- cleanup 失败:execution root 是 disposable artifact,不改变 accepted truth,由 maintenance 回收。 + +## 5. 平台矩阵 + +| 平台 | kernel 合同 | 产品 gate | +| --- | --- | --- | +| Linux | Node permission + enforcing OS sandbox;单一输出;Gitoxide/SQLite 收敛 | v4 real Host kill/reopen | +| macOS | 与 Linux 相同;路径先 realpath/canonicalize | v4 real Host kill/reopen | +| Windows | Node permission 限制 input/child;network 仍依赖外层 enforcing sandbox | 未有完整证据前 profile unavailable | + +v1 只承诺 process-crash convergence,不承诺硬件断电持久性。任何平台无法证明 sandbox profile 时必须在 T1 前 +报告 unavailable,不能回落到普通 Node、Bash 或用户 checkout。 diff --git a/docs/architecture/runtime-durable-coding-m3-m5-roadmap.zh-CN.md b/docs/architecture/runtime-durable-coding-m3-m5-roadmap.zh-CN.md index ea2f17859a..e3453db5d5 100644 --- a/docs/architecture/runtime-durable-coding-m3-m5-roadmap.zh-CN.md +++ b/docs/architecture/runtime-durable-coding-m3-m5-roadmap.zh-CN.md @@ -193,3 +193,8 @@ Host 后,新的 Run 只采用 durable outcome/candidate/evidence;已完成 每个 PR 必须列出 owner、原子性边界、失败状态、回滚/收敛方式和平台矩阵。CI 全绿只表示已布置用例通过; 并发、崩溃与数据安全仍需单独论证。 + +当前 workspace-transform kernel 已采用 `managed_mutation_v3`:受限 Node 进程只产生一个 owner-selected UTF-8 +输出,Gitoxide candidate 与 SQLite successor 才能把它纳入 accepted history。它不直接写 worktree,也不让 +caller 选择 executable/environment。下一切片才加入 packaged profile v4、Desktop negotiation 与 Host crash +gate;之后再推进 external-effect fencing。详见 `managed-node-transform-kernel-v1.zh-CN.md`。 diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index c193975e7b..6b143c940d 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -38,8 +38,8 @@ import { MANAGED_OBSERVATION_EXECUTION_PROFILE_V2_SPEC, MANAGED_OBSERVATION_EXECUTION_PROFILE_V3_DIGEST, MANAGED_OBSERVATION_EXECUTION_PROFILE_V3_SPEC, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC, runtimeEventHasModelVisibleContent, type RuntimeEvent, type RuntimeEventActions, @@ -750,30 +750,17 @@ describe('RuntimeEvent actions', () => { ); }); - test('binds the managed mutation digest to its canonical execution semantics', () => { - const canonicalProfile = JSON.stringify({ - protocol: 'managed_mutation_execution_profile_v1', - toolNames: ['Write', 'Edit'], - transform: 'pure_frozen_args_only_v1', - objectFormat: 'sha1', - pathPolicyVersion: 3, - resultSnapshot: { - maxBytes: 1_048_576, - maxDepth: 64, - maxNodes: 65_536, - maxProperties: 65_536, - maxArrayLength: 65_536, - format: 'strict_json_v1', - }, - terminalAuthority: 'owner_committed_exact_outcome_v1', - genericFallback: 'forbidden', - }); - + test('binds every managed mutation to one canonical v2 execution profile', () => { + const canonicalProfile = JSON.stringify(MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC); assert.equal( - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, `sha256:${createHash('sha256').update(canonicalProfile).digest('hex')}`, ); - assert.equal(JSON.stringify(MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC), canonicalProfile); + assert.deepEqual(MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC.toolNames, [ + 'Write', + 'Edit', + 'ManagedNodeTransform', + ]); }); test('decodes only a platform-independent T1-frozen managed mutation identity', () => { @@ -791,7 +778,7 @@ describe('RuntimeEvent actions', () => { baseTreeOid: '2'.repeat(40), expectedPath: 'src/a.ts', pathPolicyVersion: 3, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, } as const; const toolDispatch = { protocol: 't1_after_preflight_v1', @@ -832,6 +819,72 @@ describe('RuntimeEvent actions', () => { } }); + test('binds one sandboxed Node transform and its output path before T1', () => { + const canonicalProfile = JSON.stringify(MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC); + assert.equal( + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + `sha256:${createHash('sha256').update(canonicalProfile).digest('hex')}`, + ); + const managedMutation = { + protocol: 'managed_mutation_v3', + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1', + baseWorkspaceVersionId: 'version_55555555555555555555555555555555', + baseAcceptedEventId: 'baseline-event-1', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPath: 'generated/output.json', + pathPolicyVersion: 3, + operationKind: 'node_transform_v1', + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + toolchainIdentityDigest: `sha256:${'a'.repeat(64)}`, + entry: { + relativePath: 'scripts/generate.mjs', + bytes: 42, + sha256: `sha256:${'b'.repeat(64)}`, + }, + args: ['--format', 'json'], + } as const; + const toolDispatch = { + protocol: 't1_after_preflight_v1', + operationId: 'operation-transform-1', + providerToolCallId: 'call-transform-1', + toolName: 'ManagedNodeTransform', + canonicalArgsHash: `sha256:${'c'.repeat(64)}`, + recoveryMode: 'reconcile', + managedMutation, + } as const; + assert.deepEqual( + decodeRuntimeEvent(baseEvent({ role: 'system', author: 'system', actions: { toolDispatch } })) + .actions?.toolDispatch?.managedMutation, + managedMutation, + ); + for (const invalid of [ + { ...managedMutation, operationKind: 'node_command_v3' }, + { + ...managedMutation, + executionProfileDigest: `sha256:${'d'.repeat(64)}`, + }, + { ...managedMutation, entry: { ...managedMutation.entry, relativePath: 'README.md' } }, + { ...managedMutation, args: ['x'.repeat(4097)] }, + { ...managedMutation, extra: true }, + ]) { + assert.throws(() => + decodeRuntimeEvent( + baseEvent({ + role: 'system', + author: 'system', + actions: { toolDispatch: { ...toolDispatch, managedMutation: invalid } as never }, + }), + ), + ); + } + }); + test('permission and user-question interactions are first-class actions', () => { const actions: RuntimeEventActions = { permissionRequest: { diff --git a/packages/core/src/__tests__/workspace-version-authority.test.ts b/packages/core/src/__tests__/workspace-version-authority.test.ts index a67543498f..d2e65e8cb6 100644 --- a/packages/core/src/__tests__/workspace-version-authority.test.ts +++ b/packages/core/src/__tests__/workspace-version-authority.test.ts @@ -75,7 +75,7 @@ describe('workspace version authority contract', () => { changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, }, }, @@ -440,7 +440,7 @@ function historySuccessorInput(): WorkspaceHistorySuccessorAuthorityInput { changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825', + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92', }, origin: { restoreId: 'restore_77777777777777777777777777777777', @@ -496,7 +496,7 @@ function successorInput(): WorkspaceSuccessorAuthorityInput { changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, origin: { operationId: 'operation-successor-1', diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 3aee0783ba..991b8c2d74 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -255,7 +255,7 @@ export interface RuntimeEventToolDispatch { canonicalArgsHash: string; recoveryMode: ToolRecoveryMode; /** T1-frozen managed workspace mutation identity. */ - managedMutation?: RuntimeEventManagedWorkspaceMutationV2; + managedMutation?: RuntimeEventManagedWorkspaceMutation; /** T1-frozen immutable accepted-world observation identity. */ managedObservation?: RuntimeEventManagedWorkspaceObservation; } @@ -455,10 +455,28 @@ export type RuntimeEventManagedWorkspaceObservation = * Runtime consumes these limits directly, so changing the execution contract * requires changing this representation and its digest together. */ -export const MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC = Object.freeze({ - protocol: 'managed_mutation_execution_profile_v1', - toolNames: Object.freeze(['Write', 'Edit'] as const), - transform: 'pure_frozen_args_only_v1', +export const MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC = Object.freeze({ + protocol: 'managed_mutation_execution_profile_v2', + toolNames: Object.freeze(['Write', 'Edit', 'ManagedNodeTransform'] as const), + transforms: Object.freeze({ + writeEdit: 'pure_frozen_args_only_v1', + nodeTransform: Object.freeze({ + kind: 'sandboxed_accepted_tree_to_single_file_v1', + acceptedInput: 'read_only_accepted_tree_v1', + output: Object.freeze({ + owner: 'disposable_single_file_v1', + maxBytes: 1_048_576, + symlink: 'forbidden', + specialFile: 'forbidden', + }), + sandbox: Object.freeze({ + required: true, + network: 'restricted', + childProcess: 'forbidden', + processPath: 'empty', + }), + }), + }), objectFormat: 'sha1', pathPolicyVersion: 3, resultSnapshot: Object.freeze({ @@ -473,8 +491,8 @@ export const MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC = Object.freeze({ genericFallback: 'forbidden', } as const); -export const MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST = - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const; +export const MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST = + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const; export interface RuntimeEventManagedWorkspaceMutationV2 { protocol: 'managed_mutation_v2'; @@ -490,9 +508,34 @@ export interface RuntimeEventManagedWorkspaceMutationV2 { baseTreeOid: string; expectedPath: string; pathPolicyVersion: 3; - executionProfileDigest: typeof MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST; + executionProfileDigest: typeof MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST; } +export interface RuntimeEventManagedWorkspaceMutationV3 { + protocol: 'managed_mutation_v3'; + repositoryId: string; + workspaceId: string; + workspaceEpochId: string; + workspaceInstanceId: string; + objectFormat: 'sha1'; + baseWorkspaceVersionId: string; + baseAcceptedEventId: string; + baseHeadRevision: number; + baseCommitOid: string; + baseTreeOid: string; + expectedPath: string; + pathPolicyVersion: 3; + operationKind: 'node_transform_v1'; + executionProfileDigest: typeof MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST; + toolchainIdentityDigest: `sha256:${string}`; + entry: RuntimeEventManagedObservationFileV1; + args: readonly string[]; +} + +export type RuntimeEventManagedWorkspaceMutation = + | RuntimeEventManagedWorkspaceMutationV2 + | RuntimeEventManagedWorkspaceMutationV3; + export interface RuntimeEventManagedMutationTerminalV1 { protocol: 'managed_mutation_terminal_v1'; operationId: string; @@ -895,6 +938,30 @@ const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = ], [], ); +const RUNTIME_MANAGED_WORKSPACE_MUTATION_V3_SHAPE = + defineObjectShape()( + [ + 'protocol', + 'repositoryId', + 'workspaceId', + 'workspaceEpochId', + 'workspaceInstanceId', + 'objectFormat', + 'baseWorkspaceVersionId', + 'baseAcceptedEventId', + 'baseHeadRevision', + 'baseCommitOid', + 'baseTreeOid', + 'expectedPath', + 'pathPolicyVersion', + 'operationKind', + 'executionProfileDigest', + 'toolchainIdentityDigest', + 'entry', + 'args', + ], + [], + ); const RUNTIME_PROTOCOL_MARKER_SHAPE = defineObjectShape()( ['toolBoundary'], [], @@ -1369,40 +1436,61 @@ function areRuntimeManagedObservationFiles(value: readonly unknown[]): boolean { function isRuntimeManagedWorkspaceMutation( value: unknown, -): value is RuntimeEventManagedWorkspaceMutationV2 { +): value is RuntimeEventManagedWorkspaceMutation { + if (!isRecord(value)) return false; + if (value.protocol === 'managed_mutation_v3') { + return ( + hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_V3_SHAPE) && + hasManagedMutationBaseIdentity(value) && + value.operationKind === 'node_transform_v1' && + value.executionProfileDigest === MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST && + isSha256Digest(value.toolchainIdentityDigest) && + isRecord(value.entry) && + hasExactShape(value.entry, RUNTIME_MANAGED_OBSERVATION_FILE_SHAPE) && + areRuntimeManagedObservationFiles([value.entry]) && + Array.isArray(value.args) && + isRuntimeManagedNodeCommandArgs(value.args) + ); + } if ( - !isRecord(value) || !hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE) || value.protocol !== 'managed_mutation_v2' || - typeof value.repositoryId !== 'string' || - !/^repository_[0-9a-f]{32}$/u.test(value.repositoryId) || - typeof value.workspaceId !== 'string' || - !/^workspace_[0-9a-f]{32}$/u.test(value.workspaceId) || - typeof value.workspaceEpochId !== 'string' || - !/^epoch_[0-9a-f]{32}$/u.test(value.workspaceEpochId) || - typeof value.workspaceInstanceId !== 'string' || - !/^instance_[0-9a-f]{32}$/u.test(value.workspaceInstanceId) || - value.objectFormat !== 'sha1' || - typeof value.baseWorkspaceVersionId !== 'string' || - !/^version_[0-9a-f]{32}$/u.test(value.baseWorkspaceVersionId) || - typeof value.baseAcceptedEventId !== 'string' || - !/^[A-Za-z0-9_-]{1,128}$/u.test(value.baseAcceptedEventId) || - typeof value.baseHeadRevision !== 'number' || - !Number.isSafeInteger(value.baseHeadRevision) || - value.baseHeadRevision < 1 || - typeof value.baseCommitOid !== 'string' || - typeof value.baseTreeOid !== 'string' || - value.pathPolicyVersion !== 3 || - value.executionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST || - !isCanonicalManagedMutationPathV1(value.expectedPath) + !hasManagedMutationBaseIdentity(value) || + value.executionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST ) { return false; } - const oidPattern = /^[0-9a-f]{40}$/u; - if (!oidPattern.test(value.baseCommitOid) || !oidPattern.test(value.baseTreeOid)) return false; return true; } +function hasManagedMutationBaseIdentity(value: Record): boolean { + const oidPattern = /^[0-9a-f]{40}$/u; + return ( + typeof value.repositoryId === 'string' && + /^repository_[0-9a-f]{32}$/u.test(value.repositoryId) && + typeof value.workspaceId === 'string' && + /^workspace_[0-9a-f]{32}$/u.test(value.workspaceId) && + typeof value.workspaceEpochId === 'string' && + /^epoch_[0-9a-f]{32}$/u.test(value.workspaceEpochId) && + typeof value.workspaceInstanceId === 'string' && + /^instance_[0-9a-f]{32}$/u.test(value.workspaceInstanceId) && + value.objectFormat === 'sha1' && + typeof value.baseWorkspaceVersionId === 'string' && + /^version_[0-9a-f]{32}$/u.test(value.baseWorkspaceVersionId) && + typeof value.baseAcceptedEventId === 'string' && + /^[A-Za-z0-9_-]{1,128}$/u.test(value.baseAcceptedEventId) && + typeof value.baseHeadRevision === 'number' && + Number.isSafeInteger(value.baseHeadRevision) && + value.baseHeadRevision >= 1 && + typeof value.baseCommitOid === 'string' && + oidPattern.test(value.baseCommitOid) && + typeof value.baseTreeOid === 'string' && + oidPattern.test(value.baseTreeOid) && + value.pathPolicyVersion === 3 && + isCanonicalManagedMutationPathV1(value.expectedPath) + ); +} + /** Platform-independent canonical Git path syntax used by durable mutation facts. */ export function isCanonicalManagedMutationPathV1(path: unknown): path is string { if ( diff --git a/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-receipt-crash-child.ts b/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-receipt-crash-child.ts index 0cddee09a9..d9a0398ce6 100644 --- a/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-receipt-crash-child.ts +++ b/packages/runtime-host/src/__tests__/fixtures/gitoxide-candidate-receipt-crash-child.ts @@ -23,7 +23,7 @@ import { discoverMarkedStorageRoot, tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; -import { MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST } from '@maka/core/runtime-event'; import { admitGitoxideHelperArtifactInternal, issueGitoxideHelperReleaseArtifactClaimInternal, @@ -135,6 +135,6 @@ await authority.capture({ operationId: fixture.operationId, path: fixture.path, content: fixture.content, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, }); throw new Error('Crash fixture did not stop after candidate publication'); diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-write-edit-owner-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-write-edit-owner-internal.test.ts index 7d8f38538f..78fa899871 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-write-edit-owner-internal.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-write-edit-owner-internal.test.ts @@ -25,7 +25,7 @@ import { mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test, { after, type TestContext } from 'node:test'; -import { MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { WORKSPACE_MATERIALIZATION_SEMANTICS_V1 } from '@maka/core/workspace-version-authority'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; @@ -222,7 +222,7 @@ test('does not report a current projection while a durable mutation reservation baseTreeOid: treeOid, expectedPath: args.path, pathPolicyVersion: 3, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, }, }, }, diff --git a/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts index 32e1b2e09e..c97f03c30e 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts @@ -25,7 +25,7 @@ import { mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test, { type TestContext } from 'node:test'; -import { MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST } from '@maka/core/runtime-event'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { admitGitoxideHelperArtifactInternal, @@ -550,7 +550,7 @@ test('publishes a durable operation-bound candidate receipt and rejects tamperin operationId: 'operation-durable-receipt', path: 'docs/result.txt', content: 'durable result\n', - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, } as const; const first = await authority.capture(request); @@ -582,7 +582,7 @@ test('publishes a durable operation-bound candidate receipt and rejects tamperin expectedCandidateCommitOid: first.receipt.candidateCommitOid, expectedCandidateTreeOid: first.receipt.candidateTreeOid, expectedPath: request.path, - expectedExecutionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + expectedExecutionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, }); assert.deepEqual(reopened.receipt, first.receipt); assert.equal(reopenedAuthority.validate(reopened), reopened.receipt); @@ -608,7 +608,7 @@ test('publishes a durable operation-bound candidate receipt and rejects tamperin expectedCandidateCommitOid: first.receipt.candidateCommitOid, expectedCandidateTreeOid: first.receipt.candidateTreeOid, expectedPath: request.path, - expectedExecutionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + expectedExecutionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, }), (error) => error instanceof GitoxideMutationCandidateAuthorityError && @@ -686,7 +686,7 @@ test('converges a ref-only candidate publication by replaying the pure request', operationId: 'operation-ref-only', path: 'result.txt', content: 'replayed pure result\n', - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, } as const; await assert.rejects(interrupted.capture(request), /simulated process stop/); @@ -801,7 +801,7 @@ test('recovers a ref-only candidate after the publishing process exits', async ( operationId: fixture.operationId, path: fixture.path, content: fixture.content, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, }); assert.equal( proof.receipt.candidateCommitOid, diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index 435d30f1be..8ec45f9da6 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts @@ -181,7 +181,7 @@ test('the managed coding profile reads and mutates only the accepted Git tree', } for (const tool of selected.slice(3)) { assert.equal(tool.recoveryMode, 'reconcile'); - assert.equal(tool.durableExecutionProfile, 'managed_mutation_v1'); + assert.equal(tool.durableExecutionProfile, 'managed_mutation_v2'); } }); diff --git a/packages/runtime-host/src/__tests__/managed-command-sandbox-owner-internal.test.ts b/packages/runtime-host/src/__tests__/managed-command-sandbox-owner-internal.test.ts index bfb2c49e69..4cd9323e5b 100644 --- a/packages/runtime-host/src/__tests__/managed-command-sandbox-owner-internal.test.ts +++ b/packages/runtime-host/src/__tests__/managed-command-sandbox-owner-internal.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import test from 'node:test'; @@ -499,6 +499,119 @@ test('runs one explicit accepted-tree Node entrypoint without PATH, network, or await assert.rejects(stat(join(inputRoot, 'scripts', 'tampered.txt'))); }); +test('runs one accepted-tree transform into the owner-selected single output file', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-node-transform-')); + t.after(() => rm(root, { recursive: true, force: true })); + const inputRoot = join(root, 'input'); + const scratchRoot = join(root, 'scratch'); + await Promise.all([mkdir(join(inputRoot, 'scripts'), { recursive: true }), mkdir(scratchRoot)]); + await writeFile(join(inputRoot, 'source.txt'), 'accepted world\n', 'utf8'); + await writeFile( + join(inputRoot, 'scripts', 'generate.mjs'), + [ + "import { readFile, writeFile } from 'node:fs/promises';", + "import { spawnSync } from 'node:child_process';", + "const source = await readFile(new URL('../source.txt', import.meta.url), 'utf8');", + "let inputWrite = 'allowed';", + "try { await writeFile(new URL('../tampered.txt', import.meta.url), 'bad'); } catch { inputWrite = 'blocked'; }", + "let child = 'allowed';", + "try { spawnSync(process.execPath, ['--version']); } catch { child = 'blocked'; }", + 'await writeFile(process.env.MAKA_OUTPUT_PATH, `${source.trim()}|${process.argv[2]}|${inputWrite}|${child}\\n`);', + "console.log('generated one bounded output');", + '', + ].join('\n'), + 'utf8', + ); + const executablePath = resolve( + process.cwd(), + 'node_modules', + 'electron', + 'dist', + process.platform === 'win32' ? 'electron.exe' : 'electron', + ); + const entrypointPath = resolve( + import.meta.dirname, + '..', + 'server', + 'managed-command-helper-main.js', + ); + const executable = await fileIdentity(executablePath); + const entrypoint = await fileIdentity(entrypointPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const capability = await admitManagedToolchainArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim: issueManagedToolchainReleaseClaimInternal(releaseOwnerToken, { + executablePath, + executableSha256: executable.sha256, + executableBytes: executable.bytes, + entrypointPath, + entrypointSha256: entrypoint.sha256, + entrypointBytes: entrypoint.bytes, + nodeVersion: '24.18.1', + platform: process.platform, + arch: process.arch, + profileVersion: 1, + allowedEffectClasses: ['hermetic_observation_v3', 'workspace_transform_v1'], + }), + }); + const transformedRequests: SandboxTransformRequest[] = []; + const owner = createManagedCommandSandboxOwnerInternal({ + invocationOwnerToken, + dependencyLeaseConsumerOwnerToken: {}, + toolchainCapability: capability, + sandboxManager: { + transform(request): SandboxTransformResult { + transformedRequests.push(request); + return { + ok: true, + exec: { + argv: [request.command.program, ...request.command.args], + cwd: request.command.cwd, + env: request.command.env, + sandboxType: 'windows', + effectiveProfile: request.command.profile, + }, + sandboxType: 'windows', + requiresSandbox: true, + preference: 'require', + }; + }, + }, + }); + + const result = await owner.runNodeTransform!({ + inputRoot, + scratchRoot, + entryPath: 'scripts/generate.mjs', + outputPath: 'generated/output.txt', + args: ['stable'], + }); + assert.equal(result.path, 'generated/output.txt'); + assert.equal(result.content, 'accepted world|stable|blocked|blocked\n'); + assert.equal(result.bytes, Buffer.byteLength(result.content)); + assert.equal( + result.sha256, + `sha256:${createHash('sha256').update(result.content).digest('hex')}`, + ); + assert.equal(result.stdout, 'generated one bounded output\n'); + assert.equal(result.stderr, ''); + assert.equal( + await stat(join(scratchRoot, 'maka-transform-output')).then((value) => value.size), + result.bytes, + ); + await assert.rejects(stat(join(inputRoot, 'tampered.txt')), /ENOENT/u); + assert.equal(await readFile(join(inputRoot, 'source.txt'), 'utf8'), 'accepted world\n'); + assert.ok( + transformedRequests.some( + (request) => + request.command.env?.MAKA_OUTPUT_PATH === join(scratchRoot, 'maka-transform-output'), + ), + ); + assert.ok(transformedRequests.every((request) => request.command.env?.PATH === '')); +}); + async function fileIdentity(path: string) { const digest = createHash('sha256'); for await (const chunk of createReadStream(path)) digest.update(chunk); diff --git a/packages/runtime-host/src/__tests__/managed-node-transform-admission-owner-internal.test.ts b/packages/runtime-host/src/__tests__/managed-node-transform-admission-owner-internal.test.ts new file mode 100644 index 0000000000..6dc48230b2 --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-node-transform-admission-owner-internal.test.ts @@ -0,0 +1,199 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST } from '@maka/core/runtime-event'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { createManagedNodeTestExecutionRootOwnerInternal } from '../server/managed-node-test-admission-owner-internal.js'; +import { createManagedNodeTransformOwnerInternal } from '../server/managed-node-transform-admission-owner-internal.js'; + +const HEAD = Object.freeze({ + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceVersionId: 'version_55555555555555555555555555555555', + acceptedEventId: 'accepted-event-1', + revision: 2, + commitOid: '1'.repeat(40), + treeOid: '2'.repeat(40), +}); +const EPOCH = Object.freeze({ + protocol: 'workspace_epoch_opened_v1' as const, + repositoryId: HEAD.repositoryId, + workspaceId: HEAD.workspaceId, + workspaceEpochId: HEAD.workspaceEpochId, + workspaceInstanceId: 'instance_44444444444444444444444444444444', + mode: 'managed_worktree' as const, + objectFormat: 'sha1' as const, + sourceCommitOid: '3'.repeat(40), + sourceTreeOid: '4'.repeat(40), + initialWorkspaceVersionId: HEAD.workspaceVersionId, + materializationProfileDigest: `sha256:${'5'.repeat(64)}` as const, + materializationSemantics: 'git_tree_materialized_with_fixed_config_v1' as const, + policyHash: `sha256:${'6'.repeat(64)}` as const, + authority: { + sessionId: 'maka_workspace_authority' as const, + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + }, + epochOpenedEventId: 'epoch-opened-1', + committedAt: 1, +}); + +test('freezes one accepted-tree transformer and returns one owner-bound output proof', async (t) => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-managed-node-transform-')); + const rootCapability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const storageOwner = await tryAcquireInteractiveRootOwner(rootCapability); + assert.ok(storageOwner); + t.after(async () => { + await storageOwner.close(); + await rm(storageRoot, { recursive: true, force: true }); + }); + const entryContent = [ + "import { writeFile } from 'node:fs/promises';", + "await writeFile(process.env.MAKA_OUTPUT_PATH, 'generated\\n');", + '', + ].join('\n'); + const owner = createManagedNodeTransformOwnerInternal({ + executionRootOwner: createManagedNodeTestExecutionRootOwnerInternal({ + storageRootLease: storageOwner.lease, + }), + sourceOwner: { + readAcceptedBoundary: async () => ({ + repositoryId: HEAD.repositoryId, + workspaceId: HEAD.workspaceId, + workspaceEpochId: HEAD.workspaceEpochId, + workspaceInstanceId: EPOCH.workspaceInstanceId, + acceptedWorkspaceVersionId: HEAD.workspaceVersionId, + acceptedEventId: HEAD.acceptedEventId, + acceptedHeadRevision: HEAD.revision, + acceptedCommitOid: HEAD.commitOid, + acceptedTreeOid: HEAD.treeOid, + }), + materializeAcceptedTree: async (request) => { + const entry = join(request.destinationPath, 'scripts', 'generate.mjs'); + const output = join(request.destinationPath, 'generated', 'output.txt'); + await Promise.all([ + mkdir(dirname(entry), { recursive: true }), + mkdir(dirname(output), { recursive: true }), + ]); + await Promise.all([ + writeFile(entry, entryContent, 'utf8'), + writeFile(output, 'old\n', 'utf8'), + ]); + return { acceptedCommitOid: HEAD.commitOid, acceptedTreeOid: HEAD.treeOid }; + }, + }, + commandOwner: { + readToolchainIdentity: async (effectClass) => { + assert.equal(effectClass, 'workspace_transform_v1'); + return { + identityDigest: `sha256:${'7'.repeat(64)}`, + nodeVersion: '24.18.1', + platform: process.platform, + arch: process.arch, + }; + }, + readDependencyIdentity: async () => assert.fail('dependency authority is out of scope'), + inspectFile: async (request) => { + assert.equal(request.effectClass, 'workspace_transform_v1'); + const bytes = await readFile(join(request.inputRoot, ...request.relativePath.split('/'))); + return { + protocolVersion: 1, + kind: 'file_observation', + relativePath: request.relativePath, + bytes: bytes.byteLength, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + }; + }, + runNodeTests: async () => assert.fail('test authority is out of scope'), + runNodeTransform: async (request) => ({ + protocolVersion: 1, + kind: 'workspace_transform', + nodeVersion: '24.18.1', + entry: { + relativePath: request.entryPath, + bytes: Buffer.byteLength(entryContent), + sha256: `sha256:${createHash('sha256').update(entryContent).digest('hex')}`, + }, + path: request.outputPath, + content: 'generated\n', + bytes: Buffer.byteLength('generated\n'), + sha256: `sha256:${createHash('sha256').update('generated\n').digest('hex')}`, + stdout: 'generated one file\n', + stderr: '', + }), + }, + }); + const abortSignal = new AbortController().signal; + const prepared = await owner.admission.prepare({ + request: { + operationId: 'operation-transform-1', + toolName: 'ManagedNodeTransform', + persistedArgs: { + entryPath: 'scripts/generate.mjs', + path: 'generated/output.txt', + args: ['--stable'], + }, + abortSignal, + }, + head: HEAD, + epoch: EPOCH, + }); + assert.equal(prepared.durableDispatch.protocol, 'managed_mutation_v3'); + assert.equal( + prepared.durableDispatch.executionProfileDigest, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + ); + assert.deepEqual(prepared.durableDispatch.args, ['--stable']); + const transformed = await owner.tool.managedWorkspaceTransform!( + { + entryPath: 'scripts/generate.mjs', + path: 'generated/output.txt', + args: ['--stable'], + }, + { + operationId: 'operation-transform-1', + abortSignal, + } as never, + ); + assert.deepEqual(transformed.mutationResult, { + path: 'generated/output.txt', + content: 'generated\n', + changed: true, + }); + await assert.rejects( + async () => + await owner.tool.managedWorkspaceTransform!( + { entryPath: 'scripts/generate.mjs', path: 'generated/output.txt', args: ['--stable'] }, + { operationId: 'operation-transform-1', abortSignal } as never, + ), + /capability is unavailable/u, + ); + await prepared.dispose(); + const ownerRoot = join(storageRoot, 'managed-node-test-executions'); + const remaining = await stat(ownerRoot).catch(() => undefined); + assert.equal(remaining, undefined); +}); diff --git a/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts b/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts index 2efa4525d4..a1c1abc767 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-session-owner-internal.ts @@ -20,7 +20,7 @@ import { createHash } from 'node:crypto'; import { mkdir, realpath } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST } from '@maka/core/runtime-event'; import type { ManagedWorkspaceContinuationBoundaryV1 } from '@maka/core/runtime-boundary'; import { WORKSPACE_MATERIALIZATION_SEMANTICS_V1, @@ -100,8 +100,14 @@ import { } from './resumable-workspace-source-admission-internal.js'; import type { ManagedNodeTestAcceptedBoundaryInternal, + ManagedNodeTestExecutionRootOwnerInternal, ManagedNodeTestSourceOwnerInternal, } from './managed-node-test-admission-owner-internal.js'; +import type { ManagedCommandSandboxOwnerInternal } from './managed-command-sandbox-owner-internal.js'; +import { + createManagedNodeTransformOwnerInternal, + type ManagedNodeTransformOwnerInternal, +} from './managed-node-transform-admission-owner-internal.js'; const MANAGED_REPOSITORY_DIRECTORY = 'gitoxide-managed-repositories'; const ACCEPTED_REF = 'refs/maka/accepted'; @@ -119,6 +125,7 @@ export interface GitoxideManagedSessionOwnerInternal { readonly workspaceEpochId: string; readonly inspection: GitoxideManagedInspectionOwnerInternal; readonly nodeTestSource: ManagedNodeTestSourceOwnerInternal; + readonly nodeTransform: ManagedNodeTransformOwnerInternal | undefined; readonly publish: GitoxideManagedPublishOwnerInternal; readonly sourceBranchPublish: GitoxideManagedSourceBranchPublishOwnerInternal | undefined; readonly review: GitoxideManagedReviewOwnerInternal; @@ -215,7 +222,7 @@ export async function inspectGitoxideManagedContinuationBoundaryInternal(input: const boundary = await authority.readContinuationBoundary( identity.workspaceId, identity.workspaceEpochId, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, ); if (!boundary) return undefined; if ( @@ -301,6 +308,10 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { readonly sessionId: string; readonly workspaceEpochSeed?: string; readonly abortSignal?: AbortSignal; + readonly managedNodeTransform?: Readonly<{ + executionRootOwner: ManagedNodeTestExecutionRootOwnerInternal; + commandOwner: ManagedCommandSandboxOwnerInternal; + }>; readonly failpoint?: (point: GitoxideManagedSessionOwnerFailpoint) => void | Promise; }): Promise { input.abortSignal?.throwIfAborted(); @@ -377,7 +388,7 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { ); const policyHash = workspaceMutationPolicyHashV1( materializationProfileDigest, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, ); const baselineOwnerToken = {}; const verifiedBaselines = new WeakMap(); @@ -543,16 +554,6 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { await baselineAuthority.commitBaseline(imported.proof); } - const writeEdit = createGitoxideManagedWriteEditOwnerInternal({ - storageRootLease: input.storageRootLease, - stores: input.stores, - invocationOwnerToken: input.invocationOwnerToken, - helperCapability: input.helperCapability, - repositoryPath, - workspaceId: identity.workspaceId, - workspaceEpochId: identity.workspaceEpochId, - workspaceInstanceId: identity.workspaceInstanceId, - }); const inspection = createGitoxideManagedInspectionOwnerInternal({ invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, @@ -672,6 +673,24 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { }); }, }); + const nodeTransform = input.managedNodeTransform + ? createManagedNodeTransformOwnerInternal({ + executionRootOwner: input.managedNodeTransform.executionRootOwner, + sourceOwner: nodeTestSource, + commandOwner: input.managedNodeTransform.commandOwner, + }) + : undefined; + const writeEdit = createGitoxideManagedWriteEditOwnerInternal({ + storageRootLease: input.storageRootLease, + stores: input.stores, + invocationOwnerToken: input.invocationOwnerToken, + helperCapability: input.helperCapability, + repositoryPath, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + workspaceInstanceId: identity.workspaceInstanceId, + ...(nodeTransform ? { managedNodeTransform: nodeTransform.admission } : {}), + }); const review = createGitoxideManagedReviewOwnerInternal({ invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, @@ -943,6 +962,7 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { workspaceEpochId: identity.workspaceEpochId, inspection, nodeTestSource, + nodeTransform, publish, sourceBranchPublish, review, diff --git a/packages/runtime-host/src/server/gitoxide-managed-write-edit-owner-internal.ts b/packages/runtime-host/src/server/gitoxide-managed-write-edit-owner-internal.ts index c58a5b467f..652e66a19c 100644 --- a/packages/runtime-host/src/server/gitoxide-managed-write-edit-owner-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-managed-write-edit-owner-internal.ts @@ -21,9 +21,11 @@ import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { isCanonicalManagedMutationPathV1, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, type RuntimeEvent, + type RuntimeEventManagedWorkspaceMutation, type RuntimeEventManagedWorkspaceMutationV2, + type RuntimeEventManagedWorkspaceMutationV3, } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import type { @@ -93,9 +95,23 @@ export interface GitoxideManagedWriteEditOwnerInputInternal { readonly workspaceId: string; readonly workspaceEpochId: string; readonly workspaceInstanceId: string; + readonly managedNodeTransform?: GitoxideManagedNodeTransformAdmissionInternal; readonly failpoint?: (point: GitoxideManagedWriteEditOwnerFailpoint) => void | Promise; } +export interface GitoxideManagedNodeTransformAdmissionInternal { + prepare(input: { + readonly request: Parameters>[0]; + readonly head: WorkspaceHeadRecordV1; + readonly epoch: WorkspaceEpochRecordV1; + }): Promise< + Readonly<{ + durableDispatch: RuntimeEventManagedWorkspaceMutationV3; + dispose(): Promise; + }> + >; +} + export function createGitoxideManagedWriteEditOwnerInternal( input: GitoxideManagedWriteEditOwnerInputInternal, ): GitoxideManagedWriteEditOwnerInternal { @@ -120,8 +136,10 @@ export function createGitoxideManagedWriteEditOwnerInternal( const admitManagedMutation: NonNullable = async ( request, ) => { - if (request.toolName !== 'Write' && request.toolName !== 'Edit') { - throw new Error('Gitoxide managed mutation admits only Write and Edit'); + const isWriteEdit = request.toolName === 'Write' || request.toolName === 'Edit'; + const isNodeTransform = request.toolName === 'ManagedNodeTransform'; + if (!isWriteEdit && (!isNodeTransform || !input.managedNodeTransform)) { + throw new Error('Gitoxide managed mutation tool authority is unavailable'); } const path = requireCanonicalPath(request.persistedArgs); const epoch = await persistence.readEpoch(input.workspaceId, input.workspaceEpochId); @@ -148,12 +166,14 @@ export function createGitoxideManagedWriteEditOwnerInternal( managedTreePolicyVersion: 3, abortSignal: request.abortSignal, }); - const baseContent = await readAcceptedFile({ - acceptedRepositoryOwnerToken, - acceptedRepositoryCapability: accepted.acceptedRepositoryCapability, - path, - abortSignal: request.abortSignal, - }); + const baseContent = isWriteEdit + ? await readAcceptedFile({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: accepted.acceptedRepositoryCapability, + path, + abortSignal: request.abortSignal, + }) + : undefined; const candidateAuthority = await createGitoxideMutationCandidateAuthorityInternal({ storageRootLease: input.storageRootLease, baseHead: head, @@ -161,15 +181,15 @@ export function createGitoxideManagedWriteEditOwnerInternal( acceptedRepositoryCapability: accepted.acceptedRepositoryCapability, projectionOwnerToken: ownerToken, }); - const durableDispatch = freezeManagedDispatch({ - epoch, - head, - expectedPath: path, - }); + const transform = isNodeTransform + ? await input.managedNodeTransform!.prepare({ request, head, epoch }) + : undefined; + const durableDispatch = + transform?.durableDispatch ?? freezeManagedDispatch({ epoch, head, expectedPath: path }); const admission: RuntimeManagedMutationAdmission = Object.freeze({ durableDispatch, - immutableBase: Object.freeze({ content: baseContent }), + ...(isWriteEdit ? { immutableBase: Object.freeze({ content: baseContent ?? null }) } : {}), execute: async (operation: () => Promise) => settleManagedMutation({ request, @@ -182,9 +202,10 @@ export function createGitoxideManagedWriteEditOwnerInternal( candidateAuthority, issuedSuccessors, ownerToken, + durableDispatch, failpoint: input.failpoint, }), - dispose: async () => undefined, + dispose: async () => transform?.dispose(), }); return admission; }; @@ -291,7 +312,7 @@ export function createGitoxideManagedWriteEditOwnerInternal( expectedCandidateCommitOid: version.commitOid, expectedCandidateTreeOid: version.treeOid, expectedPath: path, - expectedExecutionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + expectedExecutionProfileDigest: version.executionProfileDigest, }); const successor = buildSuccessor({ operationId: version.origin.operationId, @@ -374,6 +395,7 @@ async function settleManagedMutation(input: { >; readonly issuedSuccessors: WeakMap; readonly ownerToken: object; + readonly durableDispatch: RuntimeEventManagedWorkspaceMutation; readonly failpoint?: (point: GitoxideManagedWriteEditOwnerFailpoint) => void | Promise; }): Promise { const proof = await input.operation(); @@ -399,10 +421,16 @@ async function settleManagedMutation(input: { operationId: input.request.operationId, path: input.path, content: mutation.content, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: input.durableDispatch.executionProfileDigest, abortSignal: input.request.abortSignal, }); - assertCandidateProof(candidate, input.head, input.path, mutation.content); + assertCandidateProof( + candidate, + input.head, + input.path, + mutation.content, + input.durableDispatch.executionProfileDigest, + ); } catch (error) { return Object.freeze({ kind: 'unsettled' as const, error }); } @@ -525,7 +553,7 @@ function freezeManagedDispatch(input: { baseTreeOid: input.head.treeOid, expectedPath: input.expectedPath, pathPolicyVersion: 3 as const, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, }); } @@ -567,7 +595,10 @@ function reservationMatchesAdmission( reservation: Awaited< ReturnType >, - input: Pick[0], 'request' | 'path' | 'head' | 'epoch'>, + input: Pick< + Parameters[0], + 'request' | 'path' | 'head' | 'epoch' | 'durableDispatch' + >, ): boolean { return Boolean( reservation && @@ -582,7 +613,7 @@ function reservationMatchesAdmission( reservation.baseCommitOid === input.head.commitOid && reservation.baseTreeOid === input.head.treeOid && reservation.expectedPath === input.path && - reservation.executionProfileDigest === MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + reservation.executionProfileDigest === input.durableDispatch.executionProfileDigest, ); } @@ -591,6 +622,7 @@ function assertCandidateProof( head: WorkspaceHeadRecordV1, path: string, content: string, + executionProfileDigest: string, ): void { const receipt = proof.receipt; if ( @@ -605,7 +637,7 @@ function assertCandidateProof( receipt.baseTreeOid !== head.treeOid || receipt.path !== path || receipt.contentSha256 !== sha256(content) || - receipt.executionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST || + receipt.executionProfileDigest !== executionProfileDigest || !SHA1_PATTERN.test(receipt.candidateCommitOid) || !SHA1_PATTERN.test(receipt.candidateTreeOid) || !SHA1_PATTERN.test(receipt.resultBlobOid) @@ -645,7 +677,7 @@ function buildSuccessor(input: { changedPaths: Object.freeze([receipt.path]), changedFileCount: 1, deletedFileCount: 0, - executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + executionProfileDigest: receipt.executionProfileDigest, }), origin: Object.freeze({ operationId: input.operationId, @@ -728,7 +760,7 @@ function validateAcceptedSuccessorEvidence(input: { const outcomeContent = outcome.content; if ( callContent?.kind !== 'function_call' || - (callContent.name !== 'Write' && callContent.name !== 'Edit') || + !managedMutationMatchesToolName(callContent.name, managed) || call.refs?.operationId !== input.successorVersion.origin.operationId || dispatch.id !== input.successorVersion.origin.dispatchEventId || dispatch.refs?.operationId !== input.successorVersion.origin.operationId || @@ -752,7 +784,7 @@ function validateAcceptedSuccessorEvidence(input: { path !== managed.expectedPath || input.successorVersion.changedPaths.length !== 1 || input.successorVersion.changedPaths[0] !== path || - input.successorVersion.executionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST + input.successorVersion.executionProfileDigest !== managed.executionProfileDigest ) { throw new Error('Gitoxide projection recovery path authority is invalid'); } @@ -768,13 +800,12 @@ function isAcceptedRefTargetMismatch(error: unknown): error is GitoxideHelperInv } function managedMutationMatchesParent( - managed: RuntimeEventManagedWorkspaceMutationV2 | undefined, + managed: RuntimeEventManagedWorkspaceMutation | undefined, epoch: WorkspaceEpochRecordV1, parent: WorkspaceHeadRecordV1, -): managed is RuntimeEventManagedWorkspaceMutationV2 { +): managed is RuntimeEventManagedWorkspaceMutation { return Boolean( managed && - managed.protocol === 'managed_mutation_v2' && managed.repositoryId === parent.repositoryId && managed.workspaceId === parent.workspaceId && managed.workspaceEpochId === parent.workspaceEpochId && @@ -786,10 +817,19 @@ function managedMutationMatchesParent( managed.baseCommitOid === parent.commitOid && managed.baseTreeOid === parent.treeOid && managed.pathPolicyVersion === 3 && - managed.executionProfileDigest === MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, + managed.executionProfileDigest === MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, ); } +function managedMutationMatchesToolName( + toolName: string, + managed: RuntimeEventManagedWorkspaceMutation | undefined, +): boolean { + return managed?.protocol === 'managed_mutation_v2' + ? toolName === 'Write' || toolName === 'Edit' + : managed?.protocol === 'managed_mutation_v3' && toolName === 'ManagedNodeTransform'; +} + function parentMatchesSuccessor( parent: WorkspaceVersionRecordV1, successor: Extract, diff --git a/packages/runtime-host/src/server/gitoxide-mutation-candidate-receipt-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-mutation-candidate-receipt-authority-internal.ts index 00ca96833c..4a1c1613c5 100644 --- a/packages/runtime-host/src/server/gitoxide-mutation-candidate-receipt-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-mutation-candidate-receipt-authority-internal.ts @@ -22,7 +22,7 @@ import { constants } from 'node:fs'; import { chmod, lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; -import { MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST } from '@maka/core/runtime-event'; import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; import { @@ -386,7 +386,7 @@ export async function createGitoxideMutationCandidateAuthorityInternal(input: { ): Promise => { if ( request.operationId.length === 0 || - request.expectedExecutionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST + !isManagedMutationExecutionProfileDigest(request.expectedExecutionProfileDigest) ) { throw new GitoxideMutationCandidateAuthorityError( 'gitoxide_mutation_candidate_request_invalid', @@ -533,7 +533,7 @@ function assertCaptureInput(input: GitoxideMutationCandidateCaptureInput): void if ( input.operationId.length === 0 || Buffer.byteLength(input.operationId, 'utf8') > 1024 || - input.executionProfileDigest !== MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST + !isManagedMutationExecutionProfileDigest(input.executionProfileDigest) ) { throw new GitoxideMutationCandidateAuthorityError( 'gitoxide_mutation_candidate_request_invalid', @@ -542,6 +542,10 @@ function assertCaptureInput(input: GitoxideMutationCandidateCaptureInput): void } } +function isManagedMutationExecutionProfileDigest(value: string): boolean { + return value === MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST; +} + export async function readGitoxideMutationCandidateReceiptInternal( path: string, ): Promise { diff --git a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts index 07526805e1..c63c5d0e9f 100644 --- a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts +++ b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts @@ -165,7 +165,7 @@ export function projectHostedExecutionTools( return { ...tool, recoveryMode: 'reconcile', - durableExecutionProfile: 'managed_mutation_v1', + durableExecutionProfile: 'managed_mutation_v2', }; } return tool.name === 'Bash' diff --git a/packages/runtime-host/src/server/managed-command-sandbox-owner-internal.ts b/packages/runtime-host/src/server/managed-command-sandbox-owner-internal.ts index bc0de44f45..9fe718e1ff 100644 --- a/packages/runtime-host/src/server/managed-command-sandbox-owner-internal.ts +++ b/packages/runtime-host/src/server/managed-command-sandbox-owner-internal.ts @@ -17,7 +17,9 @@ * under the License. */ -import { lstat, realpath } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { lstat, open, realpath } from 'node:fs/promises'; import { dirname, join, win32 } from 'node:path'; import type { PermissionProfileManaged } from '@maka/core/permission-profile'; import { @@ -72,9 +74,22 @@ export interface ManagedNodeCommandObservationInternal { readonly stderr: string; } +export interface ManagedNodeTransformResultInternal { + readonly protocolVersion: 1; + readonly kind: 'workspace_transform'; + readonly nodeVersion: string; + readonly entry: ManagedNodeTestFileIdentityInternal; + readonly path: string; + readonly content: string; + readonly bytes: number; + readonly sha256: `sha256:${string}`; + readonly stdout: string; + readonly stderr: string; +} + export interface ManagedCommandSandboxOwnerInternal { readToolchainIdentity( - effectClass?: 'hermetic_observation_v2' | 'hermetic_observation_v3', + effectClass?: 'hermetic_observation_v2' | 'hermetic_observation_v3' | 'workspace_transform_v1', ): Promise; readDependencyIdentity( lease: ManagedDependencySnapshotLease, @@ -88,6 +103,9 @@ export interface ManagedCommandSandboxOwnerInternal { runNodeEntrypoint?( input: ManagedCommandRunNodeEntrypointInputInternal, ): Promise; + runNodeTransform?( + input: ManagedCommandRunNodeTransformInputInternal, + ): Promise; } export interface ManagedCommandDependencyIdentityInternal { @@ -110,7 +128,10 @@ export interface ManagedCommandInspectFileInputInternal { readonly relativePath: string; readonly inputRoot: string; readonly scratchRoot: string; - readonly effectClass?: 'hermetic_observation_v2' | 'hermetic_observation_v3'; + readonly effectClass?: + | 'hermetic_observation_v2' + | 'hermetic_observation_v3' + | 'workspace_transform_v1'; readonly abortSignal?: AbortSignal; } @@ -130,6 +151,11 @@ export interface ManagedCommandRunNodeEntrypointInputInternal { readonly abortSignal?: AbortSignal; } +export interface ManagedCommandRunNodeTransformInputInternal + extends ManagedCommandRunNodeEntrypointInputInternal { + readonly outputPath: string; +} + export function createManagedCommandSandboxOwnerInternal(input: { readonly invocationOwnerToken: object; readonly dependencyLeaseConsumerOwnerToken: object; @@ -150,7 +176,10 @@ export function createManagedCommandSandboxOwnerInternal(input: { readonly kind: 'helper'; readonly operation: 'inspect_file_v1' | 'inspect_files_v1'; readonly relativePaths: readonly string[]; - readonly effectClass?: 'hermetic_observation_v2' | 'hermetic_observation_v3'; + readonly effectClass?: + | 'hermetic_observation_v2' + | 'hermetic_observation_v3' + | 'workspace_transform_v1'; } | { readonly kind: 'node_tests'; @@ -160,6 +189,7 @@ export function createManagedCommandSandboxOwnerInternal(input: { readonly kind: 'node_entrypoint'; readonly entryPath: string; readonly args: readonly string[]; + readonly outputPath?: string; }, ): Promise<{ readonly stdout: string; @@ -184,10 +214,14 @@ export function createManagedCommandSandboxOwnerInternal(input: { const toolchain = await verifyManagedToolchainForInvocationInternal( input.invocationOwnerToken, input.toolchainCapability, - invocation.kind === 'node_entrypoint' || - (invocation.kind === 'helper' && invocation.effectClass === 'hermetic_observation_v3') - ? 'hermetic_observation_v3' - : 'hermetic_observation_v2', + invocation.kind === 'node_entrypoint' && invocation.outputPath !== undefined + ? 'workspace_transform_v1' + : invocation.kind === 'helper' && invocation.effectClass === 'workspace_transform_v1' + ? 'workspace_transform_v1' + : invocation.kind === 'node_entrypoint' || + (invocation.kind === 'helper' && invocation.effectClass === 'hermetic_observation_v3') + ? 'hermetic_observation_v3' + : 'hermetic_observation_v2', ); request.abortSignal?.throwIfAborted(); const profile = hermeticObservationProfile(inputRoot, scratchRoot, dependency?.dependencyRoot); @@ -223,7 +257,10 @@ export function createManagedCommandSandboxOwnerInternal(input: { program: toolchain.executablePath, args: runtimeArgs, cwd: inputRoot, - env: hermeticEnvironment(scratchRoot), + env: hermeticEnvironment( + scratchRoot, + invocation.kind === 'node_entrypoint' ? invocation.outputPath : undefined, + ), profile, pathContext: { workspaceRoots: [ @@ -272,7 +309,7 @@ export function createManagedCommandSandboxOwnerInternal(input: { ? result.exitCode !== 0 : invocation.kind === 'node_tests' ? result.exitCode !== 0 && result.exitCode !== 1 - : false) || + : invocation.outputPath !== undefined && result.exitCode !== 0) || !result.dispatched ) { throw new Error(formatManagedCommandFailure(result, invocationLabel(invocation))); @@ -288,7 +325,8 @@ export function createManagedCommandSandboxOwnerInternal(input: { async readToolchainIdentity( effectClass: | 'hermetic_observation_v2' - | 'hermetic_observation_v3' = 'hermetic_observation_v2', + | 'hermetic_observation_v3' + | 'workspace_transform_v1' = 'hermetic_observation_v2', ) { const toolchain = await verifyManagedToolchainForInvocationInternal( input.invocationOwnerToken, @@ -433,9 +471,117 @@ export function createManagedCommandSandboxOwnerInternal(input: { stderr: result.stderr, }); }, + async runNodeTransform(request: ManagedCommandRunNodeTransformInputInternal) { + if ( + !isPortableRelativePath(request.entryPath) || + !/\.(?:cjs|mjs|js)$/u.test(request.entryPath) || + !isPortableRelativePath(request.outputPath) || + !areManagedNodeCommandArgs(request.args) + ) { + throw new Error('Managed Node transform arguments are invalid'); + } + const physicalOutputPath = join(request.scratchRoot, 'maka-transform-output'); + if (await lstat(physicalOutputPath).catch(() => undefined)) { + throw new Error('Managed Node transform output root is not empty'); + } + const observationRequest = { + inputRoot: request.inputRoot, + scratchRoot: request.scratchRoot, + ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}), + }; + const before = await execute(observationRequest, { + kind: 'helper', + operation: 'inspect_file_v1', + relativePaths: [request.entryPath], + effectClass: 'hermetic_observation_v3', + }); + const entry = decodeObservation(before.stdout, request.entryPath, before.nodeVersion); + const result = await execute(request, { + kind: 'node_entrypoint', + entryPath: request.entryPath, + args: Object.freeze([...request.args]), + outputPath: physicalOutputPath, + }); + const after = await execute(observationRequest, { + kind: 'helper', + operation: 'inspect_file_v1', + relativePaths: [request.entryPath], + effectClass: 'hermetic_observation_v3', + }); + const afterEntry = decodeObservation(after.stdout, request.entryPath, after.nodeVersion); + if ( + entry.relativePath !== afterEntry.relativePath || + entry.bytes !== afterEntry.bytes || + entry.sha256 !== afterEntry.sha256 + ) { + throw new Error('Managed Node transform entry changed during execution'); + } + const output = await readExactTransformOutput(physicalOutputPath); + return Object.freeze({ + protocolVersion: 1 as const, + kind: 'workspace_transform' as const, + nodeVersion: result.nodeVersion, + entry: Object.freeze({ + relativePath: entry.relativePath, + bytes: entry.bytes, + sha256: entry.sha256, + }), + path: request.outputPath, + content: output.content, + bytes: output.bytes, + sha256: output.sha256, + stdout: result.stdout, + stderr: result.stderr, + }); + }, }); } +async function readExactTransformOutput(path: string): Promise< + Readonly<{ + content: string; + bytes: number; + sha256: `sha256:${string}`; + }> +> { + const before = await lstat(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > 1_048_576) { + throw new Error('Managed Node transform output must be one bounded regular file'); + } + const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW); + const handle = await open(path, flags); + try { + const opened = await handle.stat(); + if (!opened.isFile() || opened.size !== before.size || opened.size > 1_048_576) { + throw new Error('Managed Node transform output identity changed before read'); + } + const bytes = await handle.readFile(); + const after = await handle.stat(); + if ( + bytes.byteLength !== opened.size || + after.size !== opened.size || + after.mtimeMs !== opened.mtimeMs + ) { + throw new Error('Managed Node transform output changed while read'); + } + let content: string; + try { + content = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw new Error('Managed Node transform output is not canonical UTF-8 text', { + cause: error, + }); + } + return Object.freeze({ + content, + bytes: bytes.byteLength, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + }); + } finally { + await handle.close(); + } +} + function uniqueWindowsVolumeRoots(paths: readonly string[]): readonly string[] { const roots: string[] = []; for (const path of paths) { @@ -453,7 +599,10 @@ function invocationLabel( readonly kind: 'helper'; readonly operation: 'inspect_file_v1' | 'inspect_files_v1'; readonly relativePaths: readonly string[]; - readonly effectClass?: 'hermetic_observation_v2' | 'hermetic_observation_v3'; + readonly effectClass?: + | 'hermetic_observation_v2' + | 'hermetic_observation_v3' + | 'workspace_transform_v1'; } | { readonly kind: 'node_tests'; readonly relativePaths: readonly string[] } | { @@ -525,7 +674,10 @@ function hermeticObservationProfile( }; } -function hermeticEnvironment(scratchRoot: string): Readonly> { +function hermeticEnvironment( + scratchRoot: string, + outputPath?: string, +): Readonly> { return Object.freeze({ ELECTRON_RUN_AS_NODE: '1', HOME: scratchRoot, @@ -536,6 +688,7 @@ function hermeticEnvironment(scratchRoot: string): Readonly { diff --git a/packages/runtime-host/src/server/managed-node-transform-admission-owner-internal.ts b/packages/runtime-host/src/server/managed-node-transform-admission-owner-internal.ts new file mode 100644 index 0000000000..fb2a268af1 --- /dev/null +++ b/packages/runtime-host/src/server/managed-node-transform-admission-owner-internal.ts @@ -0,0 +1,351 @@ +/* + * 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 { + isCanonicalManagedMutationPathV1, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + type RuntimeEventManagedObservationFileV1, + type RuntimeEventManagedWorkspaceMutationV3, +} from '@maka/core/runtime-event'; +import type { + WorkspaceEpochRecordV1, + WorkspaceHeadRecordV1, +} from '@maka/core/workspace-version-authority'; +import type { + MakaTool, + MakaToolContext, + RuntimeManagedMutationResultProof, +} from '@maka/runtime/tool-runtime'; +import { createHash } from 'node:crypto'; +import { lstat, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { z } from 'zod'; +import type { GitoxideManagedNodeTransformAdmissionInternal } from './gitoxide-managed-write-edit-owner-internal.js'; +import type { + ManagedCommandSandboxOwnerInternal, + ManagedNodeTransformResultInternal, +} from './managed-command-sandbox-owner-internal.js'; +import { + readManagedObservationExecutionRootInternal, + type ManagedNodeTestExecutionRootOwnerInternal, + type ManagedNodeTestSourceOwnerInternal, +} from './managed-node-test-admission-owner-internal.js'; + +const SHA1_PATTERN = /^[0-9a-f]{40}$/u; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const OPERATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; + +export interface ManagedNodeTransformArgsInternal { + readonly entryPath: string; + readonly path: string; + readonly args?: readonly string[]; +} + +export interface ManagedNodeTransformOwnerInternal { + readonly tool: MakaTool; + readonly admission: GitoxideManagedNodeTransformAdmissionInternal; +} + +const PARAMETERS = z + .object({ + entryPath: z.string().describe('One explicit JavaScript transformer from the accepted tree'), + path: z.string().describe('One canonical accepted-workspace output path'), + args: z.array(z.string()).max(64).optional(), + }) + .strict(); + +export function createManagedNodeTransformOwnerInternal(input: { + readonly executionRootOwner: ManagedNodeTestExecutionRootOwnerInternal; + readonly sourceOwner: ManagedNodeTestSourceOwnerInternal; + readonly commandOwner: ManagedCommandSandboxOwnerInternal; +}): ManagedNodeTransformOwnerInternal { + const prepared = new Map(); + const tool: MakaTool = Object.freeze({ + name: 'ManagedNodeTransform', + displayName: 'Managed Node Transform', + description: + 'Run one accepted-tree JavaScript transformer and publish exactly one bounded UTF-8 output through Gitoxide and the durable workspace successor authority.', + parameters: PARAMETERS, + categoryHint: 'custom_tool', + recoveryMode: 'reconcile', + durableExecutionProfile: 'managed_mutation_v2', + executionSemantics: 'exclusive_step', + nesting: 'direct_only', + impl: async () => { + throw new Error('Managed Node transform requires owner admission'); + }, + managedWorkspaceTransform: async ( + rawArgs: ManagedNodeTransformArgsInternal, + context: MakaToolContext, + ) => { + const args = requireArgs(rawArgs); + const operationId = context.operationId; + const state = operationId ? prepared.get(operationId) : undefined; + if ( + !state || + state.executed || + args.entryPath !== state.entry.relativePath || + args.path !== state.path || + !sameArgs(args.args, state.args) + ) { + throw new Error('Managed Node transform execution capability is unavailable'); + } + state.executed = true; + const run = input.commandOwner.runNodeTransform; + if (!run) throw new Error('Managed Node transform sandbox authority is unavailable'); + const result = await run({ + entryPath: args.entryPath, + outputPath: args.path, + args: args.args, + inputRoot: state.inputRoot, + scratchRoot: state.scratchRoot, + abortSignal: context.abortSignal, + }); + assertResult(result, state); + const mutationResult: RuntimeManagedMutationResultProof = Object.freeze({ + path: state.path, + content: result.content, + changed: result.content !== state.baseContent, + }); + return Object.freeze({ + result: Object.freeze({ + protocolVersion: 1, + kind: 'workspace_transform', + path: result.path, + bytes: result.bytes, + sha256: result.sha256, + stdout: result.stdout, + stderr: result.stderr, + }), + mutationResult, + }); + }, + }); + + const admission: GitoxideManagedNodeTransformAdmissionInternal = Object.freeze({ + async prepare({ + request, + head, + epoch, + }: Parameters[0]) { + request.abortSignal.throwIfAborted(); + if ( + request.toolName !== 'ManagedNodeTransform' || + !OPERATION_ID_PATTERN.test(request.operationId) || + !input.commandOwner.runNodeTransform || + prepared.has(request.operationId) + ) { + throw new Error('Managed Node transform admission identity is invalid'); + } + const args = requireArgs(request.persistedArgs); + const [boundary, toolchain] = await Promise.all([ + input.sourceOwner.readAcceptedBoundary(request.abortSignal), + input.commandOwner.readToolchainIdentity('workspace_transform_v1'), + ]); + assertBoundary(boundary, head, epoch); + if (!SHA256_PATTERN.test(toolchain.identityDigest)) { + throw new Error('Managed Node transform toolchain identity is invalid'); + } + const lease = await input.executionRootOwner.allocate(); + const executionRoot = readManagedObservationExecutionRootInternal(lease); + let installed = false; + try { + const materialized = await input.sourceOwner.materializeAcceptedTree({ + destinationPath: executionRoot.inputRoot, + acceptedCommitOid: head.commitOid, + acceptedTreeOid: head.treeOid, + abortSignal: request.abortSignal, + }); + if ( + materialized.acceptedCommitOid !== head.commitOid || + materialized.acceptedTreeOid !== head.treeOid + ) { + throw new Error('Managed Node transform materialization conflicts with accepted truth'); + } + await mkdir(executionRoot.scratchRoot); + const observed = await input.commandOwner.inspectFile({ + relativePath: args.entryPath, + inputRoot: executionRoot.inputRoot, + scratchRoot: executionRoot.scratchRoot, + effectClass: 'workspace_transform_v1', + abortSignal: request.abortSignal, + }); + const entry = Object.freeze({ + relativePath: observed.relativePath, + bytes: observed.bytes, + sha256: observed.sha256, + }); + if (entry.relativePath !== args.entryPath) { + throw new Error('Managed Node transform entry identity is invalid'); + } + const baseContent = await readOptionalBoundedText(executionRoot.inputRoot, args.path); + const state: PreparedTransform = { + inputRoot: executionRoot.inputRoot, + scratchRoot: executionRoot.scratchRoot, + entry, + path: args.path, + args: args.args, + baseContent, + executed: false, + }; + prepared.set(request.operationId, state); + installed = true; + const durableDispatch: RuntimeEventManagedWorkspaceMutationV3 = Object.freeze({ + protocol: 'managed_mutation_v3', + repositoryId: head.repositoryId, + workspaceId: head.workspaceId, + workspaceEpochId: head.workspaceEpochId, + workspaceInstanceId: epoch.workspaceInstanceId, + objectFormat: 'sha1', + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPath: args.path, + pathPolicyVersion: 3, + operationKind: 'node_transform_v1', + executionProfileDigest: MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + toolchainIdentityDigest: toolchain.identityDigest, + entry, + args: args.args, + }); + return Object.freeze({ + durableDispatch, + async dispose() { + prepared.delete(request.operationId); + await input.executionRootOwner.release(lease); + }, + }); + } finally { + if (!installed) await input.executionRootOwner.release(lease); + } + }, + }); + return Object.freeze({ tool, admission }); +} + +interface PreparedTransform { + readonly inputRoot: string; + readonly scratchRoot: string; + readonly entry: RuntimeEventManagedObservationFileV1; + readonly path: string; + readonly args: readonly string[]; + readonly baseContent: string | null; + executed: boolean; +} + +function requireArgs( + value: unknown, +): Readonly<{ entryPath: string; path: string; args: readonly string[] }> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed Node transform arguments are invalid'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + const rawArgs = record.args ?? []; + if ( + keys.length < 2 || + keys.length > 3 || + keys.some((key) => key !== 'entryPath' && key !== 'path' && key !== 'args') || + typeof record.entryPath !== 'string' || + !isCanonicalManagedMutationPathV1(record.entryPath) || + !/\.(?:cjs|mjs|js)$/u.test(record.entryPath) || + !isCanonicalManagedMutationPathV1(record.path) || + !Array.isArray(rawArgs) || + rawArgs.length > 64 + ) { + throw new Error('Managed Node transform arguments are invalid'); + } + let bytes = 0; + for (const value of rawArgs) { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 4096) { + throw new Error('Managed Node transform argument is invalid'); + } + bytes += Buffer.byteLength(value, 'utf8'); + if (bytes > 32_768) throw new Error('Managed Node transform arguments are too large'); + } + return Object.freeze({ + entryPath: record.entryPath, + path: record.path, + args: Object.freeze([...rawArgs] as string[]), + }); +} + +function assertBoundary( + boundary: Awaited>, + head: WorkspaceHeadRecordV1, + epoch: WorkspaceEpochRecordV1, +): void { + if ( + boundary.repositoryId !== head.repositoryId || + boundary.workspaceId !== head.workspaceId || + boundary.workspaceEpochId !== head.workspaceEpochId || + boundary.workspaceInstanceId !== epoch.workspaceInstanceId || + boundary.acceptedWorkspaceVersionId !== head.workspaceVersionId || + boundary.acceptedEventId !== head.acceptedEventId || + boundary.acceptedHeadRevision !== head.revision || + boundary.acceptedCommitOid !== head.commitOid || + boundary.acceptedTreeOid !== head.treeOid || + !SHA1_PATTERN.test(head.commitOid) || + !SHA1_PATTERN.test(head.treeOid) + ) { + throw new Error('Managed Node transform accepted boundary is invalid'); + } +} + +function assertResult(result: ManagedNodeTransformResultInternal, state: PreparedTransform): void { + if ( + result.kind !== 'workspace_transform' || + result.protocolVersion !== 1 || + result.path !== state.path || + result.entry.relativePath !== state.entry.relativePath || + result.entry.bytes !== state.entry.bytes || + result.entry.sha256 !== state.entry.sha256 || + result.bytes !== Buffer.byteLength(result.content, 'utf8') || + !SHA256_PATTERN.test(result.sha256) || + result.sha256 !== `sha256:${createHash('sha256').update(result.content).digest('hex')}` + ) { + throw new Error('Managed Node transform result proof is invalid'); + } +} + +async function readOptionalBoundedText(root: string, relativePath: string): Promise { + const path = join(root, ...relativePath.split('/')); + const info = await lstat(path).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + if (!info) return null; + if (!info.isFile() || info.isSymbolicLink() || info.size > 1_048_576) { + throw new Error('Managed Node transform base output is not one bounded regular file'); + } + const bytes = await readFile(path); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw new Error('Managed Node transform base output is not canonical UTF-8 text', { + cause: error, + }); + } +} + +function sameArgs(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 64d4476b08..2bbe40bd4c 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -568,7 +568,7 @@ describe('ToolRuntime durable boundary', () => { }; managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; assert.deepEqual(await harness.execute(managedTool), { ok: true }); assert.deepEqual(order, [ @@ -586,6 +586,78 @@ describe('ToolRuntime durable boundary', () => { ); }); + it('adopts one owner-committed managed Node transform without invoking generic T2', async () => { + const prepared: ToolPreparedCommit[] = []; + const harness = makeHarness( + { + commitToolPrepared: async (input) => { + prepared.push(input); + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed Node transform'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async (input) => { + assert.equal(input.toolName, 'ManagedNodeTransform'); + assert.deepEqual(input.persistedArgs, { + entryPath: 'scripts/generate.mjs', + path: 'generated/output.txt', + args: ['stable'], + }); + return { + durableDispatch: managedMutationDispatchV3(), + execute: async (operation) => { + const proof = await operation(); + assert.deepEqual(proof.mutationResult, { + path: 'generated/output.txt', + content: 'generated\n', + changed: true, + }); + assert.equal(proof.durableOutcome.content?.kind, 'function_response'); + assert.equal(proof.durableOutcome.content?.name, 'ManagedNodeTransform'); + return { + kind: 'workspace_successor_committed', + durableOutcome: proof.durableOutcome, + }; + }, + dispose: async () => undefined, + }; + }, + }, + ); + const managedTool = tool(() => { + throw new Error('ordinary mutable implementation must not run'); + }); + managedTool.name = 'ManagedNodeTransform'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; + managedTool.managedWorkspaceTransform = async () => ({ + result: { ok: true, path: 'generated/output.txt' }, + mutationResult: { + path: 'generated/output.txt', + content: 'generated\n', + changed: true, + }, + }); + + assert.deepEqual( + await harness.executeWithInput(managedTool, { + entryPath: 'scripts/generate.mjs', + path: 'generated/output.txt', + args: ['stable'], + }), + { ok: true, path: 'generated/output.txt' }, + ); + assert.deepEqual( + prepared[0]?.dispatchRuntimeEvent.actions?.toolDispatch?.managedMutation, + managedMutationDispatchV3(), + ); + }); + it('gives the settlement owner a Runtime-issued immutable outcome instead of result authority', async () => { let observedOutcome: unknown; const harness = makeHarness( @@ -621,7 +693,7 @@ describe('ToolRuntime durable boundary', () => { }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await harness.executeWithInput(managedTool, { path: 'notes.txt', content: 'after\n' }); assert.ok(observedOutcome); @@ -674,7 +746,7 @@ describe('ToolRuntime durable boundary', () => { }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; const result = await harness.executeWithInput(managedTool, { path: 'notes.txt', @@ -729,7 +801,7 @@ describe('ToolRuntime durable boundary', () => { }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; managedTool.managedMutationTransform = () => { throw new Error('Host-owned immutable base must select the Runtime transform'); }; @@ -781,7 +853,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; assert.deepEqual(await harness.execute(managedTool), { ok: true }); }); @@ -809,7 +881,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /candidate state is unknown/i); assert.equal(genericOutcomeCalls, 0); @@ -846,7 +918,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /owner settlement channel failed/i); assert.equal(genericOutcomeCalls, 0); @@ -881,7 +953,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /durable outcome/i); assert.equal(genericOutcomeCalls, 0); @@ -934,7 +1006,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ source: 'runtime-original' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /mismatched durable outcome/i); assert.equal(genericOutcomeCalls, 0); @@ -979,7 +1051,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => mutableResult); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; const result = await harness.execute(managedTool); const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); @@ -1032,7 +1104,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => resultWithProtoKey); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; const result = (await harness.execute(managedTool)) as Record; @@ -1072,7 +1144,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ error: 'candidate was safely discarded' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; assert.deepEqual(await harness.execute(managedTool), { error: 'candidate was safely discarded', @@ -1114,7 +1186,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ignored: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; assert.deepEqual( await harness.executeWithInput(managedTool, { path: 'notes.txt', content: 'same' }), @@ -1160,7 +1232,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ error: 'runtime-owned-A' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; const result = await harness.execute(managedTool); const storedResult = appendedMessages.find((message) => message.type === 'tool_result'); @@ -1208,7 +1280,7 @@ describe('ToolRuntime durable boundary', () => { }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; assert.deepEqual(await harness.execute(managedTool), { error: 'candidate was safely discarded', @@ -1257,7 +1329,7 @@ describe('ToolRuntime durable boundary', () => { }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; const execution = harness.execute(managedTool); const settledBeforeRelease = await Promise.race([ @@ -1308,7 +1380,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /mismatched durable outcome/i); assert.equal( @@ -1349,7 +1421,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => oversizedResult); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects( harness.executeNested(managedTool, 32), @@ -1380,7 +1452,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ payload: 'x'.repeat(1024 * 1024 + 1) })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /tool result byte limit exceeded/i); }); @@ -1408,7 +1480,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => result); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /tool result byte limit exceeded/i); }); @@ -1442,7 +1514,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true, missing: undefined })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), /strict JSON.*undefined/i); assert.equal( @@ -1485,7 +1557,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => makeResult()); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.execute(managedTool), expectedError); assert.equal( @@ -1531,7 +1603,7 @@ describe('ToolRuntime durable boundary', () => { const managedTool = tool(() => ({ ok: true })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; await assert.rejects(harness.executeNested(managedTool), /mismatched durable outcome/i); assert.equal( @@ -1556,7 +1628,7 @@ describe('ToolRuntime durable boundary', () => { }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; - managedTool.durableExecutionProfile = 'managed_mutation_v1'; + managedTool.durableExecutionProfile = 'managed_mutation_v2'; assert.deepEqual(await harness.execute(managedTool), { error: 'Managed workspace mutation admission is unavailable before T1', @@ -2094,7 +2166,35 @@ function managedMutationDispatch(expectedPath = 'notes.txt') { expectedPath, pathPolicyVersion: 3 as const, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, + }; +} + +function managedMutationDispatchV3() { + return { + protocol: 'managed_mutation_v3' as const, + repositoryId: 'repository_11111111111111111111111111111111', + workspaceId: 'workspace_22222222222222222222222222222222', + workspaceEpochId: 'epoch_33333333333333333333333333333333', + workspaceInstanceId: 'instance_44444444444444444444444444444444', + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: 'version_55555555555555555555555555555555', + baseAcceptedEventId: 'baseline-event-1', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPath: 'generated/output.txt', + pathPolicyVersion: 3 as const, + operationKind: 'node_transform_v1' as const, + executionProfileDigest: + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, + toolchainIdentityDigest: `sha256:${'3'.repeat(64)}` as const, + entry: { + relativePath: 'scripts/generate.mjs', + bytes: 123, + sha256: `sha256:${'4'.repeat(64)}` as const, + }, + args: ['stable'], }; } diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 4becec8c86..3bc8ecc729 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -69,15 +69,15 @@ import { MANAGED_OBSERVATION_EXECUTION_PROFILE_V1_DIGEST, MANAGED_OBSERVATION_EXECUTION_PROFILE_V2_DIGEST, MANAGED_OBSERVATION_EXECUTION_PROFILE_V3_DIGEST, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST, - MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST, + MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC, TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent, type RuntimeEventManagedWorkspaceObservation, type RuntimeEventManagedWorkspaceObservationV1, type RuntimeEventManagedWorkspaceObservationV2, type RuntimeEventManagedWorkspaceObservationV3, - type RuntimeEventManagedWorkspaceMutationV2, + type RuntimeEventManagedWorkspaceMutation, } from '@maka/core/runtime-event'; import { isDeepStrictEqual } from 'node:util'; @@ -182,7 +182,7 @@ export interface MakaTool

{ recoveryMode?: ToolRecoveryMode; /** Durable execution profile selected by the Host before T1. */ durableExecutionProfile?: - | 'managed_mutation_v1' + | 'managed_mutation_v2' | 'managed_observation_v1' | 'managed_observation_v2' | 'managed_observation_v3'; @@ -191,6 +191,13 @@ export interface MakaTool

{ * frozen arguments and must not read or mutate the live workspace. */ managedMutationTransform?: (args: P) => Promise | R; + /** Sandboxed transform whose only durable effect is one owner-verified output file. */ + managedWorkspaceTransform?: ( + args: P, + ctx: MakaToolContext, + ) => + | Promise> + | Readonly<{ result: R; mutationResult: RuntimeManagedMutationResultProof }>; /** Accepted-world observation implementation; roots are issued by its admission owner. */ managedObservationImpl?: ( args: P, @@ -484,7 +491,7 @@ export type RuntimeManagedMutationSettlement = | { readonly kind: 'unsettled'; readonly error: unknown }; export interface RuntimeManagedMutationAdmission { - readonly durableDispatch: Readonly; + readonly durableDispatch: Readonly; /** Immutable accepted-tree input. It grants no permission to rewrite tool arguments. */ readonly immutableBase?: Readonly<{ content: string | null }>; execute( @@ -1431,9 +1438,11 @@ export class ToolRuntime { let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; let managedObservationAdmission: RuntimeManagedObservationAdmission | undefined; - if (tool.durableExecutionProfile === 'managed_mutation_v1') { + if (tool.durableExecutionProfile === 'managed_mutation_v2') { + const mutationToolMatches = + tool.name === 'Write' || tool.name === 'Edit' || tool.name === 'ManagedNodeTransform'; if ( - (tool.name !== 'Write' && tool.name !== 'Edit') || + !mutationToolMatches || tool.recoveryMode !== 'reconcile' || !dispatchOperationId || !this.input.runtimeCommitSink || @@ -1459,7 +1468,11 @@ export class ToolRuntime { ) { throw new Error('Managed workspace mutation immutable base is invalid'); } - if (!managedMutationAdmission.immutableBase && !tool.managedMutationTransform) { + if ( + !managedMutationAdmission.immutableBase && + !tool.managedMutationTransform && + !tool.managedWorkspaceTransform + ) { throw new Error('Managed workspace mutation has no immutable transform input'); } } catch (error) { @@ -1687,6 +1700,25 @@ export class ToolRuntime { } catch (error) { rawResult = this.errorReturn(formatSyntheticToolErrorText(error)); } + } else if (immutableSnapshot && tool.managedWorkspaceTransform) { + const transformed = await tool.managedWorkspaceTransform( + structuredClone(executionArgs) as never, + toolContext, + ); + if ( + typeof transformed.mutationResult.content !== 'string' || + transformed.mutationResult.path !== + managedMutationAdmission?.durableDispatch.expectedPath || + Buffer.byteLength(transformed.mutationResult.content, 'utf8') > 1_048_576 + ) { + throw new Error('Managed workspace transform output proof is invalid'); + } + rawResult = transformed.result; + mutationResult = Object.freeze({ + path: transformed.mutationResult.path, + content: transformed.mutationResult.content, + changed: transformed.mutationResult.changed, + }); } else { rawResult = await (immutableSnapshot ? tool.managedMutationTransform!(structuredClone(executionArgs) as never) @@ -2251,7 +2283,7 @@ export class ToolRuntime { /** The projection the model replays as its own call. */ modelFacingArgs: unknown; abortSignal: AbortSignal; - managedMutation?: Readonly; + managedMutation?: Readonly; managedObservation?: Readonly; invocationId?: string; runId?: string; @@ -2355,19 +2387,12 @@ export class ToolRuntime { try { if (input.managedMutation) { decodeRuntimeEvent(dispatchEvent); - const persistedPath = - input.persistedArgs && - typeof input.persistedArgs === 'object' && - !Array.isArray(input.persistedArgs) - ? (input.persistedArgs as { path?: unknown }).path - : undefined; if ( - (input.tool.name !== 'Write' && input.tool.name !== 'Edit') || - typeof persistedPath !== 'string' || - input.managedMutation.expectedPath !== persistedPath || - input.managedMutation.pathPolicyVersion !== 3 || - input.managedMutation.executionProfileDigest !== - MANAGED_MUTATION_EXECUTION_PROFILE_V1_DIGEST + !managedMutationDispatchMatchesToolCall( + input.tool, + input.persistedArgs, + input.managedMutation, + ) ) { throw new Error('Managed mutation admission does not match the durable tool call'); } @@ -3319,6 +3344,39 @@ function loopGateArgsKey(args: unknown, callId: string): string { } } +function managedMutationDispatchMatchesToolCall( + tool: MakaTool, + persistedArgs: unknown, + mutation: RuntimeEventManagedWorkspaceMutation, +): boolean { + if (!persistedArgs || typeof persistedArgs !== 'object' || Array.isArray(persistedArgs)) { + return false; + } + const args = persistedArgs as Record; + if ( + typeof args.path !== 'string' || + mutation.expectedPath !== args.path || + mutation.pathPolicyVersion !== 3 + ) { + return false; + } + if (mutation.protocol === 'managed_mutation_v2') { + return ( + tool.durableExecutionProfile === 'managed_mutation_v2' && + (tool.name === 'Write' || tool.name === 'Edit') && + mutation.executionProfileDigest === MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST + ); + } + return ( + tool.durableExecutionProfile === 'managed_mutation_v2' && + tool.name === 'ManagedNodeTransform' && + mutation.operationKind === 'node_transform_v1' && + mutation.executionProfileDigest === MANAGED_MUTATION_EXECUTION_PROFILE_V2_DIGEST && + args.entryPath === mutation.entry.relativePath && + isDeepStrictEqual(args.args ?? [], mutation.args) + ); +} + function computerUseSemanticSignature(args: unknown): string | undefined { if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined; const record = args as Record; @@ -3614,13 +3672,13 @@ function coerceResultContent(raw: unknown): ToolResultContent { * The walk stops at the first over-budget token and never retains a mutable * tool/owner-owned object alias. */ -const MANAGED_RESULT_MAX_BYTES = MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxBytes; -const MANAGED_RESULT_MAX_DEPTH = MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxDepth; -const MANAGED_RESULT_MAX_NODES = MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxNodes; +const MANAGED_RESULT_MAX_BYTES = MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC.resultSnapshot.maxBytes; +const MANAGED_RESULT_MAX_DEPTH = MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC.resultSnapshot.maxDepth; +const MANAGED_RESULT_MAX_NODES = MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC.resultSnapshot.maxNodes; const MANAGED_RESULT_MAX_PROPERTIES = - MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxProperties; + MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC.resultSnapshot.maxProperties; const MANAGED_RESULT_MAX_ARRAY_LENGTH = - MANAGED_MUTATION_EXECUTION_PROFILE_V1_SPEC.resultSnapshot.maxArrayLength; + MANAGED_MUTATION_EXECUTION_PROFILE_V2_SPEC.resultSnapshot.maxArrayLength; function snapshotManagedToolResult(value: unknown, maxBytes: number | undefined): unknown { const budget: ManagedResultSnapshotBudget = { diff --git a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts index 8370226c09..61b8118f0b 100644 --- a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts +++ b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts @@ -375,7 +375,7 @@ function managedMutationPreparedCommit(variant: 'a' | 'b') { expectedPath: 'notes.txt', pathPolicyVersion: 3 as const, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, }, }, diff --git a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts index 15992d3f3d..84cd6a36b0 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts @@ -623,7 +623,7 @@ function workspaceSuccessorPreparedCommit(operationId = 'workspace-successor-ope expectedPath: 'notes.txt', pathPolicyVersion: 3 as const, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, }, }, @@ -661,7 +661,7 @@ function workspaceSuccessorCommit(): WorkspaceSuccessorCommitInput { changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, origin: { operationId: 'workspace-successor-operation', diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 6f7daafbaa..20d5168c4e 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -290,6 +290,40 @@ describe('workspace version persistence authority', () => { }); }); + it('rebuilds one managed Node transform reservation from its immutable v3 T1', async () => { + await withDatabase(async ({ dbPath, store }) => { + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedTransformPreparedCommit( + baseline, + opened.head, + 'operation-transform-reservation-1', + ); + await store.commitToolPrepared(prepared); + assert.equal( + (await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId)) + ?.operationId, + prepared.operationId, + ); + const raw = new DatabaseSync(dbPath); + try { + raw.exec('DELETE FROM runtime_managed_mutation_reservations'); + } finally { + raw.close(); + } + await assert.rejects( + store.readWorkspaceHead(baseline.epoch.workspaceId, baseline.epoch.workspaceEpochId), + /mutation reservation projection is incomplete/i, + ); + await store.rebuildWorkspaceVersionProjections(); + assert.equal( + (await readActiveManagedMutationInternal(store, baseline.epoch.workspaceInstanceId)) + ?.operationId, + prepared.operationId, + ); + }); + }); + it('rejects a managed T1 whose authorized path differs from the durable tool call', async () => { await withDatabase(async ({ store }) => { const baseline = baselineInput(); @@ -556,7 +590,7 @@ describe('workspace version persistence authority', () => { changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825', + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92', }, origin: { restoreId: 'restore-history-1', @@ -1270,7 +1304,7 @@ async function prepareSuccessorCommit( expectedPath: 'notes.txt', pathPolicyVersion: 3 as const, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, }, }, @@ -1303,7 +1337,7 @@ async function prepareSuccessorCommit( changedFileCount: 1, deletedFileCount: 0, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, origin: { operationId, @@ -1400,7 +1434,7 @@ function managedPreparedCommit( expectedPath: 'notes.txt', pathPolicyVersion: 3 as const, executionProfileDigest: - 'sha256:ffdfdda9cf38f382e0c4db81dac7319cd33586a6c65051a97a15e6c41b88f825' as const, + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, }, }, }, @@ -1414,6 +1448,94 @@ function managedPreparedCommit( }; } +function managedTransformPreparedCommit( + baseline: WorkspaceBaselineAuthorityInput, + head: Awaited>['head'], + operationId: string, +) { + const toolCallId = `${operationId}-call`; + const args = { + entryPath: 'scripts/generate.mjs', + path: 'generated/output.txt', + args: ['stable'], + }; + const argsHash = canonicalToolArgsHash('ManagedNodeTransform', args); + const identity = { + sessionId: 'session-managed-transform-reservation', + invocationId: `invocation-${operationId}`, + runId: `run-${operationId}`, + turnId: `turn-${operationId}`, + }; + return { + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: { + id: `${operationId}-call-event`, + ...identity, + ts: baseline.committedAt + 1, + partial: false, + role: 'model' as const, + author: 'agent' as const, + content: { + kind: 'function_call' as const, + id: toolCallId, + name: 'ManagedNodeTransform', + args, + }, + refs: { operationId, toolCallId }, + }, + dispatchRuntimeEvent: { + id: `${operationId}-dispatch-event`, + ...identity, + ts: baseline.committedAt + 1, + partial: false, + role: 'system' as const, + author: 'system' as const, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1' as const, + operationId, + providerToolCallId: toolCallId, + toolName: 'ManagedNodeTransform', + canonicalArgsHash: argsHash, + recoveryMode: 'reconcile' as const, + managedMutation: { + protocol: 'managed_mutation_v3' as const, + repositoryId: baseline.epoch.repositoryId, + workspaceId: baseline.epoch.workspaceId, + workspaceEpochId: baseline.epoch.workspaceEpochId, + workspaceInstanceId: baseline.epoch.workspaceInstanceId, + objectFormat: 'sha1' as const, + baseWorkspaceVersionId: head.workspaceVersionId, + baseAcceptedEventId: head.acceptedEventId, + baseHeadRevision: head.revision, + baseCommitOid: head.commitOid, + baseTreeOid: head.treeOid, + expectedPath: 'generated/output.txt', + pathPolicyVersion: 3 as const, + operationKind: 'node_transform_v1' as const, + executionProfileDigest: + 'sha256:7ff4eb75e8833f7bf97eaa252f47316f609093d89aa32acdeae7fc6caaa11a92' as const, + toolchainIdentityDigest: `sha256:${'3'.repeat(64)}` as const, + entry: { + relativePath: 'scripts/generate.mjs', + bytes: 123, + sha256: `sha256:${'4'.repeat(64)}` as const, + }, + args: ['stable'], + }, + }, + }, + refs: { operationId, toolCallId }, + }, + providerToolCallId: toolCallId, + toolName: 'ManagedNodeTransform', + canonicalArgsHash: argsHash, + recoveryMode: 'reconcile' as const, + committedAt: baseline.committedAt + 1, + }; +} + function recreateWorkspaceTablesAsSchema12(database: DatabaseSync): void { database.exec(` PRAGMA foreign_keys = OFF; diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 1c96c2d98d..45631b7943 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -56,7 +56,7 @@ import { isTerminalRuntimeEvent, TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent, - type RuntimeEventManagedWorkspaceMutationV2, + type RuntimeEventManagedWorkspaceMutation, type ToolRecoveryMode, } from '@maka/core/runtime-event'; import { @@ -1492,10 +1492,9 @@ export class SqliteRuntimeStore operation.currentState !== 'prepared' || operation.resultEventId !== undefined || operation.dispatchEventId !== input.successor.origin.dispatchEventId || - operation.recoveryMode !== 'reconcile' || - (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + operation.recoveryMode !== 'reconcile' ) { - throw new Error('Workspace successor requires one prepared Write/Edit reconcile operation'); + throw new Error('Workspace successor requires one prepared managed reconcile operation'); } if (!operation.dispatchEventId) { throw new Error('Workspace successor operation is missing its dispatch event'); @@ -1518,6 +1517,7 @@ export class SqliteRuntimeStore .get(operation.operationId) as ManagedMutationReservationProjectionRow | undefined; if ( !mutation || + !managedMutationMatchesToolName(operation.toolName, mutation) || !reservation || reservation.workspace_instance_id !== mutation.workspaceInstanceId || reservation.repository_id !== mutation.repositoryId || @@ -1767,8 +1767,7 @@ export class SqliteRuntimeStore !operation.dispatchEventId || operation.dispatchEventId !== terminal.dispatchEventId || terminal.operationId !== operation.operationId || - operation.recoveryMode !== 'reconcile' || - (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + operation.recoveryMode !== 'reconcile' ) { throw new Error('Managed mutation terminal requires its exact prepared operation'); } @@ -1777,7 +1776,11 @@ export class SqliteRuntimeStore ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) : undefined; const mutation = dispatchEvent?.actions?.toolDispatch?.managedMutation; - if (!mutation || mutation.workspaceInstanceId !== terminal.workspaceInstanceId) { + if ( + !mutation || + !managedMutationMatchesToolName(operation.toolName, mutation) || + mutation.workspaceInstanceId !== terminal.workspaceInstanceId + ) { throw new Error('Managed mutation terminal requires its exact durable reservation'); } const response = toolOutcome.runtimeEvent.content; @@ -2278,7 +2281,7 @@ export class SqliteRuntimeStore !dispatch || dispatch.operationId !== origin.operationId || dispatch.recoveryMode !== 'reconcile' || - (dispatch.toolName !== 'Write' && dispatch.toolName !== 'Edit') || + !managedMutationMatchesToolName(dispatch.toolName, dispatch.managedMutation) || !epoch || !baseHead || !managedMutationMatchesAcceptedSuccessor( @@ -2328,7 +2331,7 @@ export class SqliteRuntimeStore !dispatchEvent || dispatch.operationId !== operation.operationId || dispatch.recoveryMode !== 'reconcile' || - (dispatch.toolName !== 'Write' && dispatch.toolName !== 'Edit') + !managedMutationMatchesToolName(dispatch.toolName, mutation) ) { throw new Error( `Corrupt managed mutation reservation: identity_conflict at ${dispatchEvent?.id ?? operation.operationId}`, @@ -2925,13 +2928,17 @@ export class SqliteRuntimeStore ? (callArgs as { path?: unknown }).path : undefined; if ( - (input.toolName !== 'Write' && input.toolName !== 'Edit') || + !managedMutationMatchesToolName(input.toolName, mutation) || input.recoveryMode !== 'reconcile' || input.dispatchRuntimeEvent.actions?.toolDispatch?.toolName !== input.toolName ) { - throw new Error('Managed mutation reservation requires a reconcile Write operation'); + throw new Error('Managed mutation reservation requires a matching reconcile operation'); } - if (typeof callPath !== 'string' || mutation.expectedPath !== callPath) { + if ( + typeof callPath !== 'string' || + mutation.expectedPath !== callPath || + !managedMutationMatchesCallArgs(mutation, callArgs) + ) { throw new Error('Managed mutation path does not match its durable tool call'); } if (!this.#readWorkspaceStorageRootBinding()) { @@ -4959,13 +4966,13 @@ function workspaceHeadBeforeSuccessor( } function managedMutationMatchesAcceptedSuccessor( - mutation: RuntimeEventManagedWorkspaceMutationV2 | undefined, + mutation: RuntimeEventManagedWorkspaceMutation | undefined, successor: WorkspaceVersionAcceptedV1, baseHead: WorkspaceHeadRecordV1, workspaceInstanceId: string, ): boolean { return ( - mutation?.protocol === 'managed_mutation_v2' && + mutation !== undefined && mutation.repositoryId === successor.repositoryId && mutation.workspaceId === successor.workspaceId && mutation.workspaceEpochId === successor.workspaceEpochId && @@ -4981,6 +4988,29 @@ function managedMutationMatchesAcceptedSuccessor( ); } +function managedMutationMatchesToolName( + toolName: string, + mutation: RuntimeEventManagedWorkspaceMutation | undefined, +): boolean { + return mutation?.protocol === 'managed_mutation_v2' + ? toolName === 'Write' || toolName === 'Edit' + : mutation?.protocol === 'managed_mutation_v3' && toolName === 'ManagedNodeTransform'; +} + +function managedMutationMatchesCallArgs( + mutation: RuntimeEventManagedWorkspaceMutation, + value: unknown, +): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const args = value as Record; + if (args.path !== mutation.expectedPath) return false; + if (mutation.protocol === 'managed_mutation_v2') return true; + return ( + args.entryPath === mutation.entry.relativePath && + isDeepStrictEqual(args.args ?? [], mutation.args) + ); +} + function workspaceEpochProjectionRow( authority: ScannedWorkspaceBaselineAuthority, ): WorkspaceEpochProjectionRow {