Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/architecture/managed-node-test-admission-owner-v1.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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 的工具集合保持冻结。
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}),
};
}
Loading
Loading