修正持久进程真实验收协议

将 process-session fixture 改为不依赖控制面和 namespace 身份的纯 PTY 协议
使用 durable start、readiness、cursor、stdin 哈希和 cwd 进程归零校验交互链
新增真实 Node 子进程 smoke 覆盖 readiness、精确回显与停止状态
记录 Provider 瞬态失败和可信 exec-ready 两阶段协议
同步长期决策、技术方案与沙箱验收踩坑
This commit is contained in:
AIGameCreator App
2026-07-14 06:35:43 +08:00
parent 143684011b
commit 580e4dd87f
6 changed files with 381 additions and 190 deletions
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 只标记 Linuxdeb / 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` 后才启动业务 timeouttarget exec 失败发生在 durable commit 后,revision 保守保留。`command.start` 只在 sandbox-ready 后写 process v3 commit recordexec-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-reconciliationcommit 前可确认 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 / assistantRunner 强杀后 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 历史结论,也不把本次失败描述成已验收。
## 验收命令