From 0118be41f9fff3c857f269d4be85768492d12874 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 4 Sep 2026 05:45:57 +0800 Subject: [PATCH] test(ql3): isolate unavailable capture tools under noexec mounts --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- .../ADR-0535-canonical-panel-run-control.md | 6 ++ .../ql3-local-process/test/launcher.test.cjs | 64 +++++++++++-------- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 7900c4d3..2f57e2f0 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,6 +1,6 @@ # QingLong 3.0 Architecture RFC -- D-433 验收补强(待 Linux 实跑):现有 Local API cancellation 门接入真实面板 TypeScript 客户端,以 loopback HTTP 验证 capability、版本确认、运行列表、真实日志、取消响应丢失后的同请求重试以及重启持久性。v2 报告绑定客户端源摘要并明确不包含浏览器渲染和 Owner 初始化;专项 21/21、后端 1702 pass/2 条件 skip/0 fail,本机 Docker Engine 未恢复,不能声称组合门已通过。详见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。 +- D-433 验收补强(运行中日志修复,组合门尚未通过):真实面板 TypeScript 客户端已接入 Linux Local API cancellation 门,v2 报告要求 capability、版本确认、运行列表、真实日志、取消响应丢失后的精确重试和重启持久性,并明确不包含浏览器渲染与 Owner 初始化。`ed4ba9b` 双架构暴露稀疏日志缓冲缺陷;`efb7379f` 修复后,两个已安装 headless 镜像的即时二进制日志与 6 项字节配额边界均通过,但不支持工具的 noexec 负例夹具失败,后续 Console/面板组合门未执行。夹具已收紧 PATH 并强化意外执行探针,仍须新 CI 验证,不以旧包或局部通过替代交付。详见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。 - D-433/ADR-0535(现有面板执行管理源码候选):通过可选 `panel.runControl=task_run_v1` 开放逐项 Task start 和确切 Run cancellation,不恢复旧 Cron 写接口。确认前分别展示定时绑定版本与当前任务版本;每次项目 Run 窗口最多 64 条,只显示当前 Task 匹配项,支持显式选择、状态刷新和该次执行首片日志。确认绑定 mutation UUID,结果不明时只显式重试同一请求;关闭或重登后的响应不得继续操作。客户端 14 项含规范 body parser 与新旧 capability 回归,Node 20 面板构建与 240 files / 11,993,647 bytes 裁剪审计通过;全量前端类型诊断与基线同为 41 项,无新增。此切片仍待同源远端 CI/双架构实物,不代表旧面板已完整迁移,见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。 diff --git a/docs/adr/ADR-0535-canonical-panel-run-control.md b/docs/adr/ADR-0535-canonical-panel-run-control.md index 46eb9333..9dd6a0f3 100644 --- a/docs/adr/ADR-0535-canonical-panel-run-control.md +++ b/docs/adr/ADR-0535-canonical-panel-run-control.md @@ -77,3 +77,9 @@ Node 20 legacy migration toolchain 的 production panel build 通过;裁剪包 主 CI 的 amd64/arm64 Local image job 增加安装后 headless/Console 镜像的稀疏输出与精确字节测试,保持 128 MiB、0.5 CPU、64 PID、只读、无网络及 noexec tmpfs 条件。该测试读取镜像自身 dist/assets,只把测试代码只读挂入;原实际面板客户端 Linux 组合门及其稳定步骤名不变。修复需由新提交的 Linux CI 和后续成功产物流水线证明,不把本地结果或旧归档算作已交付。 本地最终回归:Local process + Worker 163/163(首次受限环境的 3 项 loopback EPERM 已在获准环境重跑通过);完整后端 1705 项,1703 pass / 2 条件 skip / 0 fail;镜像/客户端专项 24/24,Worker 编译与类型检查、Local/Operator image audit、18-package boundary audit、YAML 解析与新增步骤 shell 语法检查通过。本机 Docker Engine 仍不可连接,没有本地 Linux/Alpine 成功证据。 + +### 安装后负例夹具修正 + +修复提交 `efb7379fa96974c181769a6d64afc7c21bf7e6f0` 的主 CI [33808509607](https://github.com/whyour/qinglong/actions/runs/33808509607) 中,amd64 `100825140097`、arm64 `100825140142` 的已安装 headless 镜像均通过 argv/shell 稀疏二进制输出以及全部 6 项精确配额边界。第 9 项“不支持采集工具”负例返回 127 而非 125,阻止了随后 Console 镜像循环及实际面板客户端门;不将这些未执行项记为通过。 + +负例在 noexec tmpfs 中创建假工具,却仍把 `/usr/bin:/bin` 加入 PATH,shell 可以在假工具不可执行时继续找到真实工具;负例命令又使用镜像中不保证存在的 `/usr/bin/touch`,因此不能可靠识别意外执行。修正只收紧测试 PATH 到隔离目录,分别覆盖工具缺失、无执行权限和主动拒绝三种情形,并以实际 Node 可执行文件写入标记作为意外执行探针。退出 125、无标记、空日志、无回执与无 FIFO 断言全部保留;不改生产工具选择、运行时摘要、noexec 挂载或 CI gate。Local process 本地 30/30,真实 noexec 镜像结果仍须后续 CI 证明。 diff --git a/packages/ql3-local-process/test/launcher.test.cjs b/packages/ql3-local-process/test/launcher.test.cjs index 6185ee9e..b283f793 100644 --- a/packages/ql3-local-process/test/launcher.test.cjs +++ b/packages/ql3-local-process/test/launcher.test.cjs @@ -363,33 +363,45 @@ for (const [label, initialBytes, producedBytes] of [ }); } -test('rejects unsupported capture utilities before running user code', async (t) => { - const { directory, receiptRoot } = fixture(t); - const bin = path.join(directory, 'unsupported-bin'); - fs.mkdirSync(bin, { mode: 0o700 }); - for (const name of ['busybox', 'head', 'stdbuf']) { - fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 1\n', { mode: 0o700 }); - } - const marker = path.join(directory, 'must-not-run'); - const logArtifactId = `local-${'e'.repeat(30)}`; - const filePath = path.join(directory, `${logArtifactId}.log`); - const launcher = new LocalProcessLauncher( - { register: async () => undefined }, - { receiptRoot, identityProvider: identityProvider() }, - ); - const handle = await launcher.start({ - runId: RUN_ID, attemptId: ATTEMPT_ID, - callbackSequence: 1, callbackToken: TOKEN, - environment: { PATH: `${bin}:/usr/bin:/bin` }, - command: { kind: 'argv', file: '/usr/bin/touch', args: [marker] }, - output: { filePath, maximumBytes: 65536, logArtifactId }, +for (const availability of ['missing', 'not-executable', 'rejecting']) { + test(`rejects unsupported capture utilities (${availability}) before running user code`, async (t) => { + const { directory, receiptRoot } = fixture(t); + const bin = path.join(directory, 'unsupported-bin'); + fs.mkdirSync(bin, { mode: 0o700 }); + if (availability !== 'missing') { + for (const name of ['busybox', 'head', 'stdbuf']) { + fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 1\n', { + mode: availability === 'not-executable' ? 0o600 : 0o700, + }); + } + } + const marker = path.join(directory, 'must-not-run'); + const logArtifactId = `local-${'e'.repeat(30)}`; + const filePath = path.join(directory, `${logArtifactId}.log`); + const launcher = new LocalProcessLauncher( + { register: async () => undefined }, + { receiptRoot, identityProvider: identityProvider() }, + ); + const handle = await launcher.start({ + runId: RUN_ID, attemptId: ATTEMPT_ID, + callbackSequence: 1, callbackToken: TOKEN, + // Keep this negative fixture isolated even on a noexec tmpfs: a shell + // must not fall through an unexecutable stub to real system utilities. + environment: { PATH: bin }, + // Use the actual installed Node binary so an accidental launch always + // writes the marker, rather than failing because /usr/bin/touch is absent. + command: { kind: 'argv', file: process.execPath, args: [ + '-e', "require('node:fs').writeFileSync(process.argv[1], 'ran');", marker, + ] }, + output: { filePath, maximumBytes: 65536, logArtifactId }, + }); + assert.deepEqual(await handle.completion, { exitCode: 125, signal: null }); + assert.equal(fs.existsSync(marker), false); + assert.equal(fs.statSync(filePath).size, 0); + assert.equal(await new CompletionReceiptFileStore(receiptRoot).read(ATTEMPT_ID), undefined); + assert.equal(fs.readdirSync(directory).some((name) => name.endsWith('.fifo')), false); }); - assert.deepEqual(await handle.completion, { exitCode: 125, signal: null }); - assert.equal(fs.existsSync(marker), false); - assert.equal(fs.statSync(filePath).size, 0); - assert.equal(await new CompletionReceiptFileStore(receiptRoot).read(ATTEMPT_ID), undefined); - assert.equal(fs.readdirSync(directory).some((name) => name.endsWith('.fifo')), false); -}); +} test('capture failure drains output without forging a truncation fact or changing user exit', async (t) => { const { directory, receiptRoot } = fixture(t);