From c9c8aba1ea158d080426ad0591a4747d9fab0a69 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 31 Aug 2026 04:31:17 +0800 Subject: [PATCH] feat(runtime-host): admit exact accepted-world node tests --- ...aged-node-test-admission-owner-v1.zh-CN.md | 71 ++++ ...time-durable-coding-m3-m5-roadmap.zh-CN.md | 9 +- ...ide-managed-session-owner-internal.test.ts | 16 + ...node-test-admission-owner-internal.test.ts | 281 ++++++++++++ ...gitoxide-managed-session-owner-internal.ts | 93 +++- .../managed-command-sandbox-owner-internal.ts | 17 + ...aged-node-test-admission-owner-internal.ts | 399 ++++++++++++++++++ .../tool-runtime-durable-boundary.test.ts | 11 +- packages/runtime/src/tool-runtime.ts | 4 +- 9 files changed, 891 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/managed-node-test-admission-owner-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/managed-node-test-admission-owner-internal.test.ts create mode 100644 packages/runtime-host/src/server/managed-node-test-admission-owner-internal.ts diff --git a/docs/architecture/managed-node-test-admission-owner-v1.zh-CN.md b/docs/architecture/managed-node-test-admission-owner-v1.zh-CN.md new file mode 100644 index 0000000000..a4d164a585 --- /dev/null +++ b/docs/architecture/managed-node-test-admission-owner-v1.zh-CN.md @@ -0,0 +1,71 @@ +# Managed Node Test Admission Owner v1 + +## 1. 范围 + +本切片把 durable `ManagedNodeTest` protocol 连接到两个已经存在的 authority:Gitoxide managed session 的 +accepted head,以及 managed command sandbox 的 opaque toolchain capability。它只建立 Runtime Host admission; +不会把工具加入现有 `managed-coding-v1`,不会改变普通 Session,也不会在缺少 enforcing sandbox 时回退到 +`node`、`npm`、package script 或 `PATH`。 + +## 2. 主要不变量 + +> 一次 Node test 的 T1 只能引用同一个 durable accepted head 物化出的精确文件,以及同一个受权 +> `hermetic_observation_v1` toolchain;T1 以后 input identity 漂移必须 fail closed。 + +具体约束: + +1. caller 只能提供排序、唯一、canonical 的显式 `.js/.mjs/.cjs` 路径; +2. Gitoxide session owner 从 SQLite epoch/head/version 重建 repository、workspace、epoch、instance、accepted + event/revision/commit/tree,不接受 caller 自报 identity; +3. materializer 在写 input root 前后重新观察 accepted boundary,并验证 helper 返回的 commit/tree; +4. command owner 从 opaque toolchain capability 提取 digest 和 Node version;admission 不接受 executable path; +5. 每个测试文件在 T1 前通过 command owner 固化 bytes/SHA-256; +6. admission 签发一次性 execution capability。它只能从 `ready -> running -> complete -> disposed` 前进; +7. test helper 返回的文件集合必须与 T1 的文件 identity 完全相同; +8. input/scratch root 只属于该 operation,结算或失败后由 admission owner 清理。 + +## 3. Owner 与原子性边界 + +| Owner | 拥有 | 不拥有 | +| --- | --- | --- | +| Gitoxide managed session | accepted epoch/head/version 与 exact-tree materialization | toolchain、T1/T2、test result | +| Command sandbox owner | verified toolchain、文件 observation、process-tree 与 sandbox profile | accepted head、Runtime result publication | +| Execution-root owner | storage-root write lease、一次性 roots 的完整生命周期 | accepted head、toolchain、T1/T2 | +| Admission owner | 三种 capability 的组合、T1 envelope | 任意 filesystem path 删除、SQLite writer、Desktop profile 选择 | +| Runtime | 线性 operation、immutable result snapshot、T1/T2 publication | Git/toolchain identity 的真实性 | + +Admission 在所有 accepted-world/toolchain/file preflight 完成后才返回 durable envelope。SQLite T1 仍由 Runtime +现有 `commitToolPrepared()` 原子写入;本切片不增加 table、projection 或第二个 writer。 + +Admission 不接收裸 `storageRoot`。execution-root owner 必须持有不可伪造的 write lease,并在 admission 从分配 +到 dispose 的完整生命周期内保持该 lease 的 inflight operation;Host drain 因而不能在活跃 test root 尚未清理时 +先关闭 storage-root owner。 + +## 4. 失败与收敛 + +- accepted boundary、materialization 或 toolchain 不可证明:T1 前拒绝并删除 operation root; +- materialized commit/tree 与 admission observation 不一致:T1 前拒绝; +- T1 后 input 文件 identity 变化:helper 不运行或结果被拒绝,进入 durable observation 的 fail-stop/recovery; +- operation 运行中 abort/timeout:等待 command owner 回收 process tree,然后清理 roots; +- T2 已提交但 live response 丢失:采用 immutable RuntimeEvent outcome,不重新解释 helper stdout; +- T1 已提交但 T2 缺失:后续 recovery 只能在同一 accepted commit/tree、文件、toolchain/profile 上重建;任一 + identity 漂移都 park,禁止 generic test fallback。 + +本切片的 rollback 仅存在于 T1 前:删除 disposable operation root。T1 后不得删除 durable fact 伪装回滚。 + +## 5. 平台矩阵 + +| 平台 | 当前切片证明 | 转为产品能力前仍需证明 | +| --- | --- | --- | +| Linux | exact Gitoxide source owner 与 Host admission contract | Bubblewrap enforcing helper + Host kill/reopen | +| macOS | 相同 identity/protocol contract | Seatbelt enforcing helper + Host kill/reopen | +| Windows | 相同 identity/protocol contract;compiled Host owner tests | packaged AppContainer/Job owner + full Host kill/reopen | + +本地环境若无法构建目标平台 Rust helper,可以运行纯 Host owner tests;真实 Gitoxide materialization 必须由仓库现有 +三平台 helper CI 执行。CI 绿只证明已布置测试,不替代 sandbox 与 crash 论证。 + +## 6. 后续产品接线 + +不要静默扩大 `managed-coding-v1`。后续独立切片应定义版本化 product profile,只有在 packaged/current-process +toolchain authority、enforcing sandbox 和 production-shaped crash test 同时可用时,才把 `ManagedNodeTest` 暴露给 +Desktop。旧 profile 与旧 session 的工具集合保持冻结。 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 e2cace9923..0b1374e7c7 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 @@ -146,9 +146,12 @@ exit status、test summary 与 artifact digest。缓存是 projection;test out 的 `node_modules`;它证明 test runner、sandbox 与有界结构化 observation; 2. durable protocol boundary 已在 T1 前绑定 accepted head、workspace epoch、测试文件 identity、 toolchain/profile 和 effect class;Runtime 以线性 operation capability 和单一 immutable result snapshot 写入 - T2。下一步由 Host admission 只从 accepted-world/toolchain opaque capability 签发这一 envelope,并补真实 - kill/reopen; -3. 需要外部包的项目在 M5.3 capability 可用前明确 unavailable,禁止静默降级。 + T2; +3. Host admission owner 已只从 Gitoxide accepted-world 与 toolchain opaque capability 签发 envelope,并用一次性 + input/scratch roots 执行显式 Node tests;它尚未改变现有 `managed-coding-v1` 产品 profile; +4. 下一步定义版本化 Desktop product profile,并在暴露工具以前补真实 Host/helper kill/reopen 与三平台 enforcing + sandbox smoke; +5. 需要外部包的项目在 M5.3 capability 可用前明确 unavailable,禁止静默降级。 ### M5.5 External-effect fencing diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts index 626e7dc988..89a35e3b38 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-session-owner-internal.test.ts @@ -72,6 +72,22 @@ test('opens one durable Gitoxide baseline and reuses it for the same session', a }); assert.equal(retry.repositoryPath, first.repositoryPath); assert.equal(retry.workspaceEpochId, first.workspaceEpochId); + const acceptedBoundary = await retry.nodeTestSource.readAcceptedBoundary(); + assert.deepEqual(acceptedBoundary, await first.nodeTestSource.readAcceptedBoundary()); + const materializationRoot = await realpath( + await mkdtemp(join(tmpdir(), 'maka-gitoxide-node-test-source-')), + ); + t.after(() => rm(materializationRoot, { recursive: true, force: true })); + const materialized = await retry.nodeTestSource.materializeAcceptedTree({ + destinationPath: join(materializationRoot, 'input'), + acceptedCommitOid: acceptedBoundary.acceptedCommitOid, + acceptedTreeOid: acceptedBoundary.acceptedTreeOid, + }); + assert.deepEqual(materialized, { + acceptedCommitOid: acceptedBoundary.acceptedCommitOid, + acceptedTreeOid: acceptedBoundary.acceptedTreeOid, + }); + assert.equal(await readFile(join(materializationRoot, 'input', 'notes.txt'), 'utf8'), 'before\n'); const admission = await retry.writeEdit.admitManagedMutation({ operationId: 'operation-session-read', toolName: 'Write', diff --git a/packages/runtime-host/src/__tests__/managed-node-test-admission-owner-internal.test.ts b/packages/runtime-host/src/__tests__/managed-node-test-admission-owner-internal.test.ts new file mode 100644 index 0000000000..a211504654 --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-node-test-admission-owner-internal.test.ts @@ -0,0 +1,281 @@ +/* + * 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 { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { MANAGED_OBSERVATION_EXECUTION_PROFILE_V1_DIGEST } from '@maka/core/runtime-event'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { + createManagedNodeTestAdmissionOwnerInternal, + createManagedNodeTestExecutionRootOwnerInternal, + type ManagedNodeTestAcceptedBoundaryInternal, +} from '../server/managed-node-test-admission-owner-internal.js'; + +const ACCEPTED_BOUNDARY: ManagedNodeTestAcceptedBoundaryInternal = Object.freeze({ + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + acceptedWorkspaceVersionId: `version_${'5'.repeat(32)}`, + acceptedEventId: 'accepted-event-1', + acceptedHeadRevision: 7, + acceptedCommitOid: '6'.repeat(40), + acceptedTreeOid: '7'.repeat(40), +}); + +test('admits one exact accepted-world Node test and removes its disposable roots', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-managed-test-admission-')); + const rootOwner = await openStorageRootOwner(storageRoot); + const source = 'test("works", () => {});\n'; + let executionRoot: string | undefined; + try { + const owner = createManagedNodeTestAdmissionOwnerInternal({ + executionRootOwner: createManagedNodeTestExecutionRootOwnerInternal({ + storageRootLease: rootOwner.lease, + }), + sourceOwner: { + readAcceptedBoundary: async () => ACCEPTED_BOUNDARY, + materializeAcceptedTree: async (request) => { + const path = join(request.destinationPath, 'src', 'a.test.mjs'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf8'); + return { + acceptedCommitOid: ACCEPTED_BOUNDARY.acceptedCommitOid, + acceptedTreeOid: ACCEPTED_BOUNDARY.acceptedTreeOid, + }; + }, + }, + commandOwner: { + readToolchainIdentity: async () => ({ + identityDigest: `sha256:${'8'.repeat(64)}`, + nodeVersion: '24.13.1', + }), + inspectFile: async (request) => ({ + protocolVersion: 1, + kind: 'file_observation', + ...(await fileIdentity(request.inputRoot, request.relativePath)), + }), + runNodeTests: async (request) => ({ + protocolVersion: 1, + kind: 'node_test_observation', + nodeVersion: '24.13.1', + files: [await fileIdentity(request.inputRoot, request.relativePaths[0]!)], + passed: 1, + failed: 0, + skipped: 0, + todo: 0, + }), + }, + }); + const abortSignal = new AbortController().signal; + const admission = await owner.admit({ + operationId: 'operation-1', + toolName: 'ManagedNodeTest', + persistedArgs: { relativePaths: ['src/a.test.mjs'] }, + abortSignal, + }); + + assert.deepEqual(admission.durableDispatch, { + protocol: 'managed_observation_v1', + ...ACCEPTED_BOUNDARY, + objectFormat: 'sha1', + operationKind: 'node_test_v1', + effectClass: 'hermetic_observation_v1', + executionProfileDigest: MANAGED_OBSERVATION_EXECUTION_PROFILE_V1_DIGEST, + toolchainIdentityDigest: `sha256:${'8'.repeat(64)}`, + files: [await fileIdentityFromContent('src/a.test.mjs', source)], + }); + + let result: unknown; + await admission.execute(async (execution) => { + executionRoot = dirname(execution.inputRoot); + result = await owner.tool.managedObservationImpl!( + { relativePaths: ['src/a.test.mjs'] }, + { abortSignal } as never, + execution, + ); + }); + assert.deepEqual(result, { + protocolVersion: 1, + kind: 'node_test_observation', + nodeVersion: '24.13.1', + files: [await fileIdentityFromContent('src/a.test.mjs', source)], + passed: 1, + failed: 0, + skipped: 0, + todo: 0, + }); + + await admission.dispose(); + assert.ok(executionRoot); + await assert.rejects(access(executionRoot), (error: unknown) => + Boolean( + error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === 'ENOENT', + ), + ); + } finally { + await rootOwner.close(); + await rm(storageRoot, { recursive: true, force: true }); + } +}); + +test('rejects a materialized test file that changes after durable admission', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-managed-test-tamper-')); + const rootOwner = await openStorageRootOwner(storageRoot); + const source = 'test("works", () => {});\n'; + try { + const owner = createManagedNodeTestAdmissionOwnerInternal({ + executionRootOwner: createManagedNodeTestExecutionRootOwnerInternal({ + storageRootLease: rootOwner.lease, + }), + sourceOwner: { + readAcceptedBoundary: async () => ACCEPTED_BOUNDARY, + materializeAcceptedTree: async (request) => { + const path = join(request.destinationPath, 'src', 'a.test.mjs'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf8'); + return { + acceptedCommitOid: ACCEPTED_BOUNDARY.acceptedCommitOid, + acceptedTreeOid: ACCEPTED_BOUNDARY.acceptedTreeOid, + }; + }, + }, + commandOwner: commandOwnerForFilesystem(), + }); + const abortSignal = new AbortController().signal; + const admission = await owner.admit({ + operationId: 'operation-tamper', + toolName: 'ManagedNodeTest', + persistedArgs: { relativePaths: ['src/a.test.mjs'] }, + abortSignal, + }); + try { + await assert.rejects( + admission.execute(async (execution) => { + await writeFile(join(execution.inputRoot, 'src', 'a.test.mjs'), 'tampered\n', 'utf8'); + await owner.tool.managedObservationImpl!( + { relativePaths: ['src/a.test.mjs'] }, + { abortSignal } as never, + execution, + ); + }), + /input changed after durable admission/u, + ); + } finally { + await admission.dispose(); + } + } finally { + await rootOwner.close(); + await rm(storageRoot, { recursive: true, force: true }); + } +}); + +test('rejects a conflicting accepted-tree materialization before durable admission', async () => { + const storageRoot = await mkdtemp(join(tmpdir(), 'maka-managed-test-conflict-')); + const rootOwner = await openStorageRootOwner(storageRoot); + try { + const owner = createManagedNodeTestAdmissionOwnerInternal({ + executionRootOwner: createManagedNodeTestExecutionRootOwnerInternal({ + storageRootLease: rootOwner.lease, + }), + sourceOwner: { + readAcceptedBoundary: async () => ACCEPTED_BOUNDARY, + materializeAcceptedTree: async (request) => { + const path = join(request.destinationPath, 'src', 'a.test.mjs'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, 'stale\n', 'utf8'); + return { + acceptedCommitOid: '9'.repeat(40), + acceptedTreeOid: ACCEPTED_BOUNDARY.acceptedTreeOid, + }; + }, + }, + commandOwner: commandOwnerForFilesystem(), + }); + + await assert.rejects( + owner.admit({ + operationId: 'operation-conflict', + toolName: 'ManagedNodeTest', + persistedArgs: { relativePaths: ['src/a.test.mjs'] }, + abortSignal: new AbortController().signal, + }), + /materialization conflicts with the accepted boundary/u, + ); + + const ownerRoot = join(storageRoot, 'managed-node-test-observations-v1'); + const entries = await readdir(ownerRoot); + assert.deepEqual(entries, []); + } finally { + await rootOwner.close(); + await rm(storageRoot, { recursive: true, force: true }); + } +}); + +async function openStorageRootOwner(storageRoot: string) { + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + return owner; +} + +async function fileIdentity(inputRoot: string, relativePath: string) { + const content = await readFile(join(inputRoot, ...relativePath.split('/'))); + return { + relativePath, + bytes: content.byteLength, + sha256: `sha256:${createHash('sha256').update(content).digest('hex')}` as const, + }; +} + +async function fileIdentityFromContent(relativePath: string, content: string) { + return { + relativePath, + bytes: Buffer.byteLength(content), + sha256: `sha256:${createHash('sha256').update(content).digest('hex')}` as const, + }; +} + +function commandOwnerForFilesystem() { + return { + readToolchainIdentity: async () => ({ + identityDigest: `sha256:${'8'.repeat(64)}` as const, + nodeVersion: '24.13.1', + }), + inspectFile: async (request: { inputRoot: string; relativePath: string }) => ({ + protocolVersion: 1 as const, + kind: 'file_observation' as const, + ...(await fileIdentity(request.inputRoot, request.relativePath)), + }), + runNodeTests: async (request: { inputRoot: string; relativePaths: readonly string[] }) => ({ + protocolVersion: 1 as const, + kind: 'node_test_observation' as const, + nodeVersion: '24.13.1', + files: [await fileIdentity(request.inputRoot, request.relativePaths[0]!)], + passed: 1, + failed: 0, + skipped: 0, + todo: 0, + }), + }; +} 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 d4b9f600ef..840573256c 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 @@ -43,7 +43,10 @@ import { requireGitoxideHelperArtifactIdentityInternal, requireGitoxideHelperOperationsInternal, } from './gitoxide-helper-artifact-authority-internal.js'; -import { importFilesystemSnapshotWithGitoxideHelperInternal } from './gitoxide-helper-invocation-internal.js'; +import { + importFilesystemSnapshotWithGitoxideHelperInternal, + materializeAcceptedTreeWithGitoxideHelperInternal, +} from './gitoxide-helper-invocation-internal.js'; import { createGitoxideManagedWriteEditOwnerInternal, type GitoxideManagedWriteEditOwnerInternal, @@ -95,6 +98,10 @@ import { requireResumableWorkspaceSourceAdmissionInternal, type ResumableWorkspaceSourceKindInternal, } from './resumable-workspace-source-admission-internal.js'; +import type { + ManagedNodeTestAcceptedBoundaryInternal, + ManagedNodeTestSourceOwnerInternal, +} from './managed-node-test-admission-owner-internal.js'; const MANAGED_REPOSITORY_DIRECTORY = 'gitoxide-managed-repositories'; const ACCEPTED_REF = 'refs/maka/accepted'; @@ -111,6 +118,7 @@ export interface GitoxideManagedSessionOwnerInternal { readonly workspaceId: string; readonly workspaceEpochId: string; readonly inspection: GitoxideManagedInspectionOwnerInternal; + readonly nodeTestSource: ManagedNodeTestSourceOwnerInternal; readonly publish: GitoxideManagedPublishOwnerInternal; readonly sourceBranchPublish: GitoxideManagedSourceBranchPublishOwnerInternal | undefined; readonly review: GitoxideManagedReviewOwnerInternal; @@ -581,6 +589,88 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { return Object.freeze({ commitOid: head.commitOid, treeOid: head.treeOid }); }, }); + const readNodeTestAcceptedBoundary = async ( + abortSignal?: AbortSignal, + ): Promise => { + abortSignal?.throwIfAborted(); + const [epoch, head] = await Promise.all([ + baselineAuthority.readEpoch(identity.workspaceId, identity.workspaceEpochId), + baselineAuthority.readHead(identity.workspaceId, identity.workspaceEpochId), + ]); + if ( + !epoch || + !head || + epoch.repositoryId !== identity.repositoryId || + epoch.workspaceId !== identity.workspaceId || + epoch.workspaceEpochId !== identity.workspaceEpochId || + epoch.workspaceInstanceId !== identity.workspaceInstanceId || + head.repositoryId !== identity.repositoryId || + head.workspaceId !== identity.workspaceId || + head.workspaceEpochId !== identity.workspaceEpochId + ) { + throw new Error('Gitoxide managed Node test durable workspace head is unavailable'); + } + const version = await baselineAuthority.readVersion(head.workspaceVersionId); + if ( + !version || + version.repositoryId !== head.repositoryId || + version.workspaceId !== head.workspaceId || + version.workspaceEpochId !== head.workspaceEpochId || + version.workspaceVersionId !== head.workspaceVersionId || + version.acceptedEventId !== head.acceptedEventId || + version.commitOid !== head.commitOid || + version.treeOid !== head.treeOid + ) { + throw new Error('Gitoxide managed Node test accepted workspace version is unavailable'); + } + abortSignal?.throwIfAborted(); + return Object.freeze({ + repositoryId: identity.repositoryId, + workspaceId: identity.workspaceId, + workspaceEpochId: identity.workspaceEpochId, + workspaceInstanceId: identity.workspaceInstanceId, + acceptedWorkspaceVersionId: version.workspaceVersionId, + acceptedEventId: version.acceptedEventId, + acceptedHeadRevision: head.revision, + acceptedCommitOid: version.commitOid, + acceptedTreeOid: version.treeOid, + }); + }; + const nodeTestSource: ManagedNodeTestSourceOwnerInternal = Object.freeze({ + readAcceptedBoundary: readNodeTestAcceptedBoundary, + async materializeAcceptedTree( + request: Parameters[0], + ) { + const current = await readNodeTestAcceptedBoundary(request.abortSignal); + if ( + request.acceptedCommitOid !== current.acceptedCommitOid || + request.acceptedTreeOid !== current.acceptedTreeOid + ) { + throw new Error( + 'Gitoxide managed Node test accepted boundary changed before materialization', + ); + } + const materialized = await materializeAcceptedTreeWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath, + acceptedCommitOid: current.acceptedCommitOid, + destinationPath: request.destinationPath, + managedTreePolicyVersion: 3, + ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}), + }); + if ( + materialized.acceptedCommitOid !== current.acceptedCommitOid || + materialized.acceptedTreeOid !== current.acceptedTreeOid + ) { + throw new Error('Gitoxide managed Node test materialization identity is invalid'); + } + return Object.freeze({ + acceptedCommitOid: materialized.acceptedCommitOid, + acceptedTreeOid: materialized.acceptedTreeOid, + }); + }, + }); const review = createGitoxideManagedReviewOwnerInternal({ invocationOwnerToken: input.invocationOwnerToken, helperCapability: input.helperCapability, @@ -846,6 +936,7 @@ export async function openGitoxideManagedSessionOwnerInternal(input: { workspaceId: identity.workspaceId, workspaceEpochId: identity.workspaceEpochId, inspection, + nodeTestSource, publish, sourceBranchPublish, review, 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 393b1f15f6..db43d3e73d 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 @@ -59,6 +59,7 @@ export interface ManagedNodeTestObservationInternal { } export interface ManagedCommandSandboxOwnerInternal { + readToolchainIdentity(): Promise; inspectFile( input: ManagedCommandInspectFileInputInternal, ): Promise; @@ -67,6 +68,11 @@ export interface ManagedCommandSandboxOwnerInternal { ): Promise; } +export interface ManagedCommandToolchainIdentityInternal { + readonly identityDigest: `sha256:${string}`; + readonly nodeVersion: string; +} + export interface ManagedCommandInspectFileInputInternal { readonly relativePath: string; readonly inputRoot: string; @@ -158,6 +164,17 @@ export function createManagedCommandSandboxOwnerInternal(input: { return { stdout: result.stdout, nodeVersion: toolchain.nodeVersion }; } return Object.freeze({ + async readToolchainIdentity() { + const toolchain = await verifyManagedToolchainForInvocationInternal( + input.invocationOwnerToken, + input.toolchainCapability, + 'hermetic_observation_v1', + ); + return Object.freeze({ + identityDigest: toolchain.identityDigest, + nodeVersion: toolchain.nodeVersion, + }); + }, async inspectFile(request: ManagedCommandInspectFileInputInternal) { if (!isPortableRelativePath(request.relativePath)) { throw new Error('Managed command observation path is invalid'); diff --git a/packages/runtime-host/src/server/managed-node-test-admission-owner-internal.ts b/packages/runtime-host/src/server/managed-node-test-admission-owner-internal.ts new file mode 100644 index 0000000000..cd750c14d7 --- /dev/null +++ b/packages/runtime-host/src/server/managed-node-test-admission-owner-internal.ts @@ -0,0 +1,399 @@ +/* + * 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 { lstat, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + isCanonicalManagedMutationPathV1, + MANAGED_OBSERVATION_EXECUTION_PROFILE_V1_DIGEST, + type RuntimeEventManagedObservationFileV1, + type RuntimeEventManagedWorkspaceObservationV1, +} from '@maka/core/runtime-event'; +import type { MakaTool, RuntimeManagedObservationAdmission } from '@maka/runtime/tool-runtime'; +import { runWithStorageRootLease, type StorageRootLease } from '@maka/storage/root-authority'; +import { z } from 'zod'; +import type { + ManagedCommandSandboxOwnerInternal, + ManagedNodeTestObservationInternal, +} from './managed-command-sandbox-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; +const MANAGED_TEST_ROOT = 'managed-node-test-observations-v1'; +const executionRootLeaseBrand: unique symbol = Symbol('ManagedNodeTestExecutionRootLease'); + +export interface ManagedNodeTestExecutionRootLeaseInternal { + readonly [executionRootLeaseBrand]: true; +} + +export interface ManagedNodeTestExecutionRootOwnerInternal { + allocate(): Promise; + release(lease: ManagedNodeTestExecutionRootLeaseInternal): Promise; +} + +interface ManagedNodeTestExecutionRootRecordInternal { + readonly inputRoot: string; + readonly scratchRoot: string; + readonly release: () => Promise; + released: boolean; +} + +const executionRootLeases = new WeakMap(); + +export interface ManagedNodeTestAcceptedBoundaryInternal { + readonly repositoryId: string; + readonly workspaceId: string; + readonly workspaceEpochId: string; + readonly workspaceInstanceId: string; + readonly acceptedWorkspaceVersionId: string; + readonly acceptedEventId: string; + readonly acceptedHeadRevision: number; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; +} + +export interface ManagedNodeTestSourceOwnerInternal { + readAcceptedBoundary(abortSignal?: AbortSignal): Promise; + materializeAcceptedTree(input: { + readonly destinationPath: string; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly abortSignal?: AbortSignal; + }): Promise<{ + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + }>; +} + +export interface ManagedNodeTestAdmissionOwnerInternal { + readonly tool: MakaTool; + admit(input: { + readonly operationId: string; + readonly toolName: string; + readonly persistedArgs: unknown; + readonly abortSignal: AbortSignal; + }): Promise; +} + +export interface ManagedNodeTestArgsInternal { + readonly relativePaths: readonly string[]; +} + +const MANAGED_NODE_TEST_PARAMETERS = z + .object({ + relativePaths: z + .array(z.string()) + .min(1) + .max(64) + .describe('Sorted explicit .js, .mjs, or .cjs files from the accepted workspace'), + }) + .strict(); + +export function createManagedNodeTestAdmissionOwnerInternal(input: { + readonly executionRootOwner: ManagedNodeTestExecutionRootOwnerInternal; + readonly sourceOwner: ManagedNodeTestSourceOwnerInternal; + readonly commandOwner: ManagedCommandSandboxOwnerInternal; +}): ManagedNodeTestAdmissionOwnerInternal { + const admittedFilesByInputRoot = new Map< + string, + readonly RuntimeEventManagedObservationFileV1[] + >(); + const tool: MakaTool = { + name: 'ManagedNodeTest', + displayName: 'Managed Node Test', + description: + 'Run explicit Node test files against the immutable accepted workspace. No package scripts, dependency installation, PATH tools, network, or child processes are available.', + parameters: MANAGED_NODE_TEST_PARAMETERS, + categoryHint: 'custom_tool', + recoveryMode: 'replay_safe', + durableExecutionProfile: 'managed_observation_v1', + executionSemantics: 'exclusive_step', + nesting: 'direct_only', + impl: async () => { + throw new Error('Managed Node test requires accepted-world admission'); + }, + managedObservationImpl: async (args, ctx, execution) => { + const relativePaths = requireManagedNodeTestArgs(args); + const observation = await input.commandOwner.runNodeTests({ + relativePaths, + inputRoot: execution.inputRoot, + scratchRoot: execution.scratchRoot, + abortSignal: ctx.abortSignal, + }); + const expectedFiles = admittedFilesByInputRoot.get(execution.inputRoot); + if (!expectedFiles) throw new Error('Managed Node test execution roots are not admitted'); + assertExactFiles(observation.files, expectedFiles); + return observation; + }, + }; + + const owner: ManagedNodeTestAdmissionOwnerInternal = { + tool: Object.freeze(tool), + async admit(request) { + request.abortSignal.throwIfAborted(); + if ( + request.toolName !== 'ManagedNodeTest' || + !OPERATION_ID_PATTERN.test(request.operationId) + ) { + throw new Error('Managed Node test admission identity is invalid'); + } + const relativePaths = requireManagedNodeTestArgs(request.persistedArgs); + const [boundary, toolchain] = await Promise.all([ + input.sourceOwner.readAcceptedBoundary(request.abortSignal), + input.commandOwner.readToolchainIdentity(), + ]); + request.abortSignal.throwIfAborted(); + assertAcceptedBoundary(boundary); + if ( + !SHA256_PATTERN.test(toolchain.identityDigest) || + !/^24\.[0-9]+\.[0-9]+$/u.test(toolchain.nodeVersion) + ) { + throw new Error('Managed Node test toolchain identity is invalid'); + } + const executionRootLease = await input.executionRootOwner.allocate(); + const executionRoot = requireManagedNodeTestExecutionRootInternal(executionRootLease); + const { inputRoot, scratchRoot } = executionRoot; + let admitted = false; + try { + await mkdir(scratchRoot); + const materialized = await input.sourceOwner.materializeAcceptedTree({ + destinationPath: inputRoot, + acceptedCommitOid: boundary.acceptedCommitOid, + acceptedTreeOid: boundary.acceptedTreeOid, + abortSignal: request.abortSignal, + }); + if ( + materialized.acceptedCommitOid !== boundary.acceptedCommitOid || + materialized.acceptedTreeOid !== boundary.acceptedTreeOid + ) { + throw new Error('Managed Node test materialization conflicts with the accepted boundary'); + } + const files: RuntimeEventManagedObservationFileV1[] = []; + for (const relativePath of relativePaths) { + request.abortSignal.throwIfAborted(); + const observed = await input.commandOwner.inspectFile({ + relativePath, + inputRoot, + scratchRoot, + abortSignal: request.abortSignal, + }); + if (observed.relativePath !== relativePath) { + throw new Error('Managed Node test file observation identity is invalid'); + } + files.push( + Object.freeze({ + relativePath, + bytes: observed.bytes, + sha256: observed.sha256, + }), + ); + } + const durableDispatch: RuntimeEventManagedWorkspaceObservationV1 = Object.freeze({ + protocol: 'managed_observation_v1', + ...boundary, + objectFormat: 'sha1', + operationKind: 'node_test_v1', + effectClass: 'hermetic_observation_v1', + executionProfileDigest: MANAGED_OBSERVATION_EXECUTION_PROFILE_V1_DIGEST, + toolchainIdentityDigest: toolchain.identityDigest, + files: Object.freeze(files), + }); + let operation: Promise | undefined; + let state: 'ready' | 'running' | 'complete' | 'disposed' = 'ready'; + admittedFilesByInputRoot.set(inputRoot, durableDispatch.files); + admitted = true; + return Object.freeze({ + durableDispatch, + execute(run: (execution: { inputRoot: string; scratchRoot: string }) => Promise) { + if (state !== 'ready') { + return Promise.reject( + new Error('Managed Node test admission is no longer executable'), + ); + } + state = 'running'; + const current = run( + Object.freeze({ + inputRoot, + scratchRoot, + }), + ).finally(() => { + if (state === 'running') state = 'complete'; + }); + operation = current; + return current; + }, + async dispose() { + if (state === 'disposed') return; + await operation?.catch(() => undefined); + state = 'disposed'; + admittedFilesByInputRoot.delete(inputRoot); + await input.executionRootOwner.release(executionRootLease); + }, + }); + } finally { + if (!admitted) await input.executionRootOwner.release(executionRootLease); + } + }, + }; + return Object.freeze(owner); +} + +export function createManagedNodeTestExecutionRootOwnerInternal(input: { + readonly storageRootLease: StorageRootLease<'interactive', 'write'>; +}): ManagedNodeTestExecutionRootOwnerInternal { + return Object.freeze({ + async allocate() { + let publishLease!: (lease: ManagedNodeTestExecutionRootLeaseInternal) => void; + let rejectLease!: (error: unknown) => void; + const leasePublished = new Promise( + (resolve, reject) => { + publishLease = resolve; + rejectLease = reject; + }, + ); + let releaseLifetime!: () => void; + const lifetimeReleased = new Promise((resolve) => { + releaseLifetime = resolve; + }); + let lifetime: Promise; + lifetime = runWithStorageRootLease( + input.storageRootLease, + 'interactive', + 'write', + async (storageRoot) => { + const ownerRoot = join(storageRoot, MANAGED_TEST_ROOT); + await mkdir(ownerRoot, { recursive: true }); + const ownerRootStat = await lstat(ownerRoot); + if (!ownerRootStat.isDirectory() || ownerRootStat.isSymbolicLink()) { + throw new Error('Managed Node test execution-root authority is invalid'); + } + const canonicalOwnerRoot = await realpath(ownerRoot); + if (!samePath(canonicalOwnerRoot, ownerRoot)) { + throw new Error('Managed Node test execution-root authority escaped its storage root'); + } + const executionRoot = await mkdtemp(join(canonicalOwnerRoot, 'observation-')); + const inputRoot = join(executionRoot, 'input'); + const scratchRoot = join(executionRoot, 'scratch'); + const lease = Object.freeze({ + [executionRootLeaseBrand]: true as const, + }); + const record: ManagedNodeTestExecutionRootRecordInternal = { + inputRoot, + scratchRoot, + released: false, + async release() { + if (record.released) return; + record.released = true; + releaseLifetime(); + await lifetime; + }, + }; + executionRootLeases.set(lease, record); + publishLease(lease); + try { + await lifetimeReleased; + } finally { + executionRootLeases.delete(lease); + await rm(executionRoot, { recursive: true, force: true }); + } + }, + ); + lifetime.catch(rejectLease); + return await leasePublished; + }, + async release(lease: ManagedNodeTestExecutionRootLeaseInternal) { + await requireManagedNodeTestExecutionRootInternal(lease).release(); + }, + }); +} + +function requireManagedNodeTestExecutionRootInternal( + lease: ManagedNodeTestExecutionRootLeaseInternal, +): ManagedNodeTestExecutionRootRecordInternal { + const record = executionRootLeases.get(lease); + if (!record || record.released) { + throw new Error('Managed Node test execution-root lease is invalid'); + } + return record; +} + +function samePath(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function requireManagedNodeTestArgs(value: unknown): readonly string[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed Node test arguments are invalid'); + } + const record = value as Record; + const keys = Object.keys(record); + if (keys.length !== 1 || keys[0] !== 'relativePaths' || !Array.isArray(record.relativePaths)) { + throw new Error('Managed Node test arguments are invalid'); + } + const relativePaths = record.relativePaths; + if ( + relativePaths.length === 0 || + relativePaths.length > 64 || + !relativePaths.every( + (path, index) => + typeof path === 'string' && + isCanonicalManagedMutationPathV1(path) && + /\.(?:cjs|mjs|js)$/u.test(path) && + (index === 0 || path > (relativePaths[index - 1] as string)), + ) + ) { + throw new Error('Managed Node test arguments must be sorted, unique canonical test paths'); + } + return Object.freeze([...relativePaths] as string[]); +} + +function assertAcceptedBoundary(boundary: ManagedNodeTestAcceptedBoundaryInternal): void { + if ( + !/^repository_[0-9a-f]{32}$/u.test(boundary.repositoryId) || + !/^workspace_[0-9a-f]{32}$/u.test(boundary.workspaceId) || + !/^epoch_[0-9a-f]{32}$/u.test(boundary.workspaceEpochId) || + !/^instance_[0-9a-f]{32}$/u.test(boundary.workspaceInstanceId) || + !/^version_[0-9a-f]{32}$/u.test(boundary.acceptedWorkspaceVersionId) || + !/^[A-Za-z0-9_-]{1,128}$/u.test(boundary.acceptedEventId) || + !Number.isSafeInteger(boundary.acceptedHeadRevision) || + boundary.acceptedHeadRevision < 1 || + !SHA1_PATTERN.test(boundary.acceptedCommitOid) || + !SHA1_PATTERN.test(boundary.acceptedTreeOid) + ) { + throw new Error('Managed Node test accepted boundary is invalid'); + } +} + +function assertExactFiles( + actual: readonly RuntimeEventManagedObservationFileV1[], + expected: readonly RuntimeEventManagedObservationFileV1[], +): void { + if ( + actual.length !== expected.length || + !actual.every( + (file, index) => + file.relativePath === expected[index]?.relativePath && + file.bytes === expected[index]?.bytes && + file.sha256 === expected[index]?.sha256, + ) + ) { + throw new Error('Managed Node test input changed after durable admission'); + } +} 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 f0c093e435..a7eb362c56 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -33,6 +33,7 @@ import { ToolRuntime, type MakaTool, type RuntimeManagedObservationAdmission, + type RuntimeManagedObservationExecution, type RuntimeManagedMutationAdmission, type RuntimeManagedMutationSettlement, type ToolRuntimeInput, @@ -114,9 +115,13 @@ describe('ToolRuntime durable boundary', () => { { admitManagedObservation: async () => ({ durableDispatch: managedObservationDispatch(), - execute: async (operation) => { - retained = operation; - await operation({ inputRoot: '/accepted', scratchRoot: '/scratch' }); + execute: async ( + operation: (execution: RuntimeManagedObservationExecution) => Promise, + ) => { + retained = async (execution) => { + await operation(execution); + }; + return await operation({ inputRoot: '/accepted', scratchRoot: '/scratch' }); }, dispose: async () => undefined, }), diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 1f97108011..0689b6b19a 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -421,9 +421,7 @@ export interface RuntimeManagedObservationExecution { export interface RuntimeManagedObservationAdmission { readonly durableDispatch: Readonly; - execute( - operation: (execution: RuntimeManagedObservationExecution) => Promise, - ): Promise; + execute(operation: (execution: RuntimeManagedObservationExecution) => Promise): Promise; /** Idempotent for an unused, failed-T1, completed, or failed observation. */ dispose(): Promise; }