修正持久进程真实验收协议
将 process-session fixture 改为不依赖控制面和 namespace 身份的纯 PTY 协议 使用 durable start、readiness、cursor、stdin 哈希和 cwd 进程归零校验交互链 新增真实 Node 子进程 smoke 覆盖 readiness、精确回显与停止状态 记录 Provider 瞬态失败和可信 exec-ready 两阶段协议 同步长期决策、技术方案与沙箱验收踩坑
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
export function buildProcessSessionFixtureSource({
|
||||
readyPrefix,
|
||||
echoPrefix,
|
||||
stoppedMarker,
|
||||
}) {
|
||||
for (const [name, value] of Object.entries({
|
||||
readyPrefix,
|
||||
echoPrefix,
|
||||
stoppedMarker,
|
||||
})) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
/[\r\n]/u.test(value)
|
||||
) {
|
||||
throw new Error(`invalid process fixture marker: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"import { randomBytes } from 'node:crypto';",
|
||||
'',
|
||||
`const readyPrefix = ${JSON.stringify(readyPrefix)};`,
|
||||
`const echoPrefix = ${JSON.stringify(echoPrefix)};`,
|
||||
`const stoppedMarker = ${JSON.stringify(stoppedMarker)};`,
|
||||
"const challenge = randomBytes(18).toString('hex');",
|
||||
'let echoed = false;',
|
||||
'let stopping = false;',
|
||||
'',
|
||||
'function stop() {',
|
||||
' if (stopping) return;',
|
||||
' stopping = true;',
|
||||
' console.log(stoppedMarker);',
|
||||
' process.exit(0);',
|
||||
'}',
|
||||
"for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);",
|
||||
"console.log(readyPrefix + ' challenge=' + challenge);",
|
||||
'',
|
||||
"process.stdin.setEncoding('utf8');",
|
||||
"let buffered = '';",
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
' buffered += chunk;',
|
||||
' const lines = buffered.split(/\\r?\\n/u);',
|
||||
" buffered = lines.pop() ?? '';",
|
||||
' for (const line of lines) {',
|
||||
' if (!echoed && line === challenge) {',
|
||||
' echoed = true;',
|
||||
" console.log(echoPrefix + ' ' + challenge);",
|
||||
' }',
|
||||
' }',
|
||||
'});',
|
||||
'process.stdin.resume();',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { buildProcessSessionFixtureSource } from '../scripts/process-session-real-e2e-fixture.mjs';
|
||||
|
||||
const readyPrefix = 'GENARRATIVE_PROCESS_READY';
|
||||
const echoPrefix = 'GENARRATIVE_PROCESS_ECHO';
|
||||
const stoppedMarker = 'GENARRATIVE_PROCESS_STOPPED';
|
||||
const temporaryRoots = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...temporaryRoots].map((root) =>
|
||||
fs.rm(root, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
temporaryRoots.clear();
|
||||
});
|
||||
|
||||
describe('process-session real E2E fixture', () => {
|
||||
it('runs as a pure PTY-style protocol without project control files or sockets', async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'genarrative-process-fixture-test-'),
|
||||
);
|
||||
temporaryRoots.add(root);
|
||||
const fixturePath = path.join(root, 'fixture.mjs');
|
||||
await fs.writeFile(
|
||||
fixturePath,
|
||||
buildProcessSessionFixtureSource({
|
||||
readyPrefix,
|
||||
echoPrefix,
|
||||
stoppedMarker,
|
||||
}),
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, [fixturePath], {
|
||||
cwd: root,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const exitPromise = once(child, 'exit');
|
||||
const output = readline.createInterface({ input: child.stdout });
|
||||
const lines: string[] = [];
|
||||
const waiters = new Set<{
|
||||
predicate: (line: string) => boolean;
|
||||
resolve: (line: string) => void;
|
||||
}>();
|
||||
output.on('line', (line) => {
|
||||
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
||||
lines.push(normalized);
|
||||
for (const waiter of [...waiters]) {
|
||||
if (waiter.predicate(normalized)) waiter.resolve(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
const waitForLine = (
|
||||
predicate: (line: string) => boolean,
|
||||
label: string,
|
||||
): Promise<string> => {
|
||||
const existing = lines.find(predicate);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const waiter = {
|
||||
predicate,
|
||||
resolve: (line) => {
|
||||
clearTimeout(timer);
|
||||
waiters.delete(waiter);
|
||||
resolve(line);
|
||||
},
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
waiters.delete(waiter);
|
||||
reject(new Error(`process fixture did not emit ${label}`));
|
||||
}, 3_000);
|
||||
waiters.add(waiter);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const readyLine = await waitForLine(
|
||||
(line) => line.startsWith(`${readyPrefix} challenge=`),
|
||||
'readiness',
|
||||
);
|
||||
const match = readyLine.match(
|
||||
/^GENARRATIVE_PROCESS_READY challenge=([0-9a-f]{36})$/u,
|
||||
);
|
||||
expect(match).not.toBeNull();
|
||||
if (!match?.[1]) throw new Error('process fixture challenge is missing');
|
||||
expect(
|
||||
await fs.stat(path.join(root, '.agent')).catch(() => null),
|
||||
).toBeNull();
|
||||
|
||||
const challenge = match[1];
|
||||
child.stdin.write(`${challenge}\n`);
|
||||
await expect(
|
||||
waitForLine((line) => line === `${echoPrefix} ${challenge}`, 'echo'),
|
||||
).resolves.toBe(`${echoPrefix} ${challenge}`);
|
||||
|
||||
expect(child.kill('SIGTERM')).toBe(true);
|
||||
await expect(
|
||||
waitForLine((line) => line === stoppedMarker, 'stopped marker'),
|
||||
).resolves.toBe(stoppedMarker);
|
||||
const [exitCode, signal] = await exitPromise;
|
||||
expect({ exitCode, signal }).toEqual({ exitCode: 0, signal: null });
|
||||
expect(await fs.readdir(root)).toEqual(['fixture.mjs']);
|
||||
} finally {
|
||||
output.close();
|
||||
child.stdin.destroy();
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -4291,3 +4291,5 @@
|
||||
- 审计与发布:process record v2 保存 launch 当时的 backend / mode / network / profile,后续 process 工具从 durable/live 身份读取,preflight 失败使用 unavailable / not-established,不能按平台静态宣称已建立。共享 `os-workspace-sandbox` capability 只标记 Linux;deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装并保持 fail-closed。
|
||||
- 长进程策略:`command.start` 只用于仓库清单确认的持续交互服务,短命令、探测、构建和测试走 `command.exec`;同一服务成功启动后只沿原 processId 操作。真实验收出现第二条 process record 时立即失败,防止模型主动重复 start 被误判成 Runtime 重放或一直等待总超时。
|
||||
- 已知残余:项目 mount preflight 与真实 bwrap launch 是两次独立进程启动。第二次 setup 失败不会让目标程序脱离沙箱执行,但当前缺少 exec-ready 握手,revision 可能已推进且审计无法证明目标是否进入 exec;后续必须在 launcher 层补可信握手,当前文档和验收不得宣称该阶段具备原子保证。
|
||||
- V1.11.1 决策:bwrap `child-pid` 只作为 child-created,不作为 sandbox-ready。Linux launcher 必须以受信任 trampoline 和独立私有控制通道完成 `SANDBOX_READY -> durable commit -> COMMIT_EXEC -> EXEC_ESTABLISHED`;commit 前失败显式 kill/reap 且目标零执行,commit 后无 exec-ready 进入 launch-unknown reconciliation。PTY 控制帧不得混入 transcript。
|
||||
- 验收修正:V1.10 process fixture 写 `.agent`、启动 TCP 并跨 namespace 使用 PID/端口,与 V1.11 安全边界冲突。V1.11 真实复验改为纯 PTY readiness/challenge/echo/stopped 协议,以唯一 durable start、cursor 链、stdin hash 和宿主项目 cwd 进程清零证明;Provider 502 的零工具计划失败单独记为外部瞬态错误。
|
||||
|
||||
@@ -2893,3 +2893,11 @@
|
||||
- 处理:外部工具链环境根 canonicalize 后必须通过窄叶目录校验;用户 HOME、HOME 符号链接目标和类型不匹配目录全部在 mount preflight 阶段失败关闭。不要为了兼容任意自定义环境根放宽成“只读就安全”。
|
||||
- 验证:直接 HOME、`.rustup -> HOME` 均返回错误且目标 program 零执行;真实 `.rustup` 叶目录仍可只读挂载,Cargo fixture build 继续通过。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs`、`command_exec.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。
|
||||
|
||||
## 沙箱内验收 fixture 不能依赖 Runtime 控制面或宿主 namespace 身份
|
||||
|
||||
- 现象:V1.10 process-session 真实验收在 V1.11 后报“缺少精确回显”,但确定性 PTY 和 sandbox 测试均通过;旧 fixture 在 readiness 前写 `.agent`,还启动 loopback server 并把进程内 PID / 端口交给宿主检查。
|
||||
- 原因:V1.11 正确地用 0000 空 mount 隐藏 `.agent`,并隔离 pid / network namespace。沙箱内 PID、loopback 端口和 Runtime 控制目录不再是宿主可观察事实;fixture 在输出 readiness 前即可能失败,统一的 interaction-evidence 错误又掩盖了真实阶段。
|
||||
- 处理:交互 fixture 只使用 PTY stdin/stdout/signal,不写 `.agent`、不监听 TCP、不持久化 PID/端口。唯一启动由 process record、start action/fingerprint、start audit 和唯一 readiness marker共同证明;Runner 强杀后的清理由 owner boot、reconciliation record 和宿主 `/proc/*/cwd` 项目进程归零证明。
|
||||
- 验证:独立真实 Node smoke 必须完成 readiness、challenge 单行原样输入、精确 echo、SIGTERM stopped,并确认项目未创建 `.agent`;E2E 分别报告 readiness / stdin hash / echo / stopped 缺失,严格检查 readiness poll -> stdin -> echo poll -> terminate -> terminal poll。Provider 在零工具计划阶段的 502/TLS 只记外部失败,不得归因到 fixture 或 Runtime。
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs`、`agent-runtime-real-e2e.mjs`、`apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。
|
||||
|
||||
@@ -573,9 +573,19 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变
|
||||
- deb / rpm 发布包声明 `bubblewrap` 宿主依赖;AppImage 不携带 bubblewrap sidecar,发布页和安装检查必须明确要求受支持版本的系统 `/usr/bin/bwrap` 或 `/bin/bwrap`。缺失时命令工具安全失败关闭,但该 AppImage 不算具备可用的通用开发能力。
|
||||
- 真实 Provider disposable E2E 不给固定 program、文件名或工具顺序,要求模型自行发现项目技术栈,运行构建、测试和 Git 检查,并用结构化审计证明所有命令都在 workspace-write / network-disabled 下执行。上述门禁通过前不得宣称 V1.11 完成。
|
||||
|
||||
### V1.11.1 可信 launch 握手
|
||||
|
||||
V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。
|
||||
|
||||
- Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。
|
||||
- `command.exec` 只在 `SANDBOX_READY` 后推进 revision 和清除旧验证凭证,`EXEC_ESTABLISHED` 后才启动业务 timeout;target exec 失败发生在 durable commit 后,revision 保守保留。`command.start` 只在 sandbox-ready 后写 process v3 commit record,exec-established 后才注册 running 和返回 processId;快速退出仍返回同一 processId 的 terminal poll。`project.verify` 必须走同一 launcher,只有 exec-established 且退出码为 0 才签发 passed gate。
|
||||
- process record v3 增加 `sandboxEstablishment / targetExec / launchFailureKind / sandboxReadyAt / execEstablishedAt`。v2 活跃记录迁移为 unknown + needs-reconciliation;commit 前可确认 kill/reap 的失败不重放,commit 后缺少 exec-ready 的窗口统一进入 `launch-unknown + needs-reconciliation`。
|
||||
- portable-pty 会关闭额外 FD,不能把控制协议混入 PTY 输出。Linux process child wrapper 需要唯一专用控制通道,只经该通道接收私有 launch plan 和交换 ready/commit/exec 帧;目标只继承 PTY stdin/stdout/stderr,控制 FD、nonce、child-pid、宿主路径和完整 bwrap argv不得进入目标 argv/env、transcript、command log、record、receipt 或 Agent DB。
|
||||
- 门禁必须覆盖乱序/重复/错 nonce/EOF、block 未放行目标 marker 为零、sandbox-ready 后持久化失败、目标不存在或无权限、目标立即 exit 0/7、PTY 快速退出和 Runner 强杀窗口,并扫描 `/proc/self/fd`、argv、env 与全部公共持久面确认控制材料泄漏为零。Windows 继续按 CreateProcess + Job 语义单独建模,不能复用或宣称 Linux 握手。
|
||||
|
||||
2026-07-14 最新真实 `gpt-5.5` `llm-runtime` 已按新增 metadata 门禁通过:123 条 task、208 条 event、220 条 Agent DB、15 次成功工具执行、2 次 `command.exec`(先失败后成功)、1 次 `project.verify`、3 个隔离实例、双视口浏览器验证、唯一 completed / assistant;Runner 强杀后 run / session 身份稳定恢复,重复、副作用重放、密钥和诱饵泄漏均为 0。保留现场独立核对 2 条 command.exec 和 1 条 project.verify 审计均为 `bubblewrap / workspace-write / disabled / workspace-v1` 后按 sentinel 清理。
|
||||
|
||||
同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start,现场同时存在大量把有限探测误用为 command.start 的失败动作;收紧策略后不再重复 start,但仍因没有形成 challenge 精确回显而以 `process-transcript-interaction-evidence-missing` 失败。13 项确定性 process session 测试和真实 PTY `setsid + chdir` 沙箱负例仍通过,process record / start / poll / stdin / terminate metadata 均正确;在新的 Provider 严格单 launch 交互套件 PASS 前,不更新 V1.10 的历史 process Provider PASS 结论,也不把本次失败描述成已验收。
|
||||
同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start;收紧策略后的旧 E2E 又暴露 V1.10 fixture 与 V1.11 sandbox 契约冲突,fixture 在 readiness 前写 `.agent`、启动 namespace 内 loopback 并把 namespace PID/端口当宿主事实,而 V1.11 正确隐藏 `.agent` 且隔离 pid/network namespace,因此 `process-transcript-interaction-evidence-missing` 不能直接归因于模型抄错 challenge。修复方向是纯 PTY fixture:不写 `.agent`、不启动 TCP、不跨 namespace 读取 PID/端口,以唯一 process record/start/readiness、连续 cursor、stdin hash、精确 echo、stopped 和宿主项目 cwd 进程清零作为事实。最新一次重跑在零工具计划阶段连续收到 Provider 502,只记外部瞬态失败,不用于判断 Runtime。新的纯 PTY Provider 套件 PASS 前,不更新 V1.10 历史结论,也不把本次失败描述成已验收。
|
||||
|
||||
## 验收命令
|
||||
|
||||
|
||||
Reference in New Issue
Block a user