diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 1f601611..45af05c4 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -435,6 +435,21 @@ jobs: run: | node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone + - name: Run authenticated Local API cancellation through real Linux processes + env: + QL3_LOCAL_API_CANCELLATION_LIVE: '1' + run: | + install -d -m 0700 "${{ runner.temp }}/ql3-local-api-cancellation" + node scripts/ql3-local-api-cancellation-live-contract.cjs \ + --profile=edge \ + --report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json" + node scripts/ql3-local-api-cancellation-live-contract.cjs \ + --profile=standalone \ + --report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json" + node scripts/ql3-local-api-cancellation-live-audit.cjs \ + --report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json" + node scripts/ql3-local-api-cancellation-live-audit.cjs \ + --report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json" cluster-image: name: Cluster ${{ matrix.image }} image (${{ matrix.image_arch }}) diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 50d39fcf..8af5ff5e 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,24 @@ 最新增量证据(2026-08-12): +- D-299/ADR-0387(已接受) + Local `run.cancel` 已从“HTTP durable intent 与 `/proc` stop 分别验证”推进为同一次真实 Linux 组合门。门先用既有离线 pack/prune + 生成可选 `edge/standalone-application-api` 最终制品,再在锁定 Node 24 arm64 Linux 容器内以 non-root、read-only root、network none、 + capabilities none、0.5 CPU、Edge 128 MiB/64 PIDs 或 Standalone 256 MiB/256 PIDs 运行单进程 Local API。真实 HTTP `task.start` + 拉起长期子进程,SQLite 持久 PID 后由 `/proc//stat` 绑定 start ticks;真实 HTTP cancellation 必须得到 + `accepted → already_requested`,最终只有 1 条 intent Event、1 条 cancelled Event、2 条 allowed audit,Run/Attempt 均 cancelled,精确 + PID/start identity 消失,SQLite integrity `ok`,API 有序重启后仍通过 HTTP 观察 cancelled。Edge/Standalone 可选 API 制品为 + 3,668,052/3,668,196 bytes、429 files、85 modules,距 6 MiB 保留 2,623,404/2,623,260 bytes;API RSS 为 + 80,736,256/78,868,480 bytes,低于对应 envelope。两份 `0600` 私有报告经独立 audit 零 finding,SHA-256 分别为 + `056f8f1c07f0c5dfe4552fcb605d6b55b194cd826dbf1c720b21fdba4bd55e53` 与 + `223b7241ec3af8edea824dd802f24d573d0987c2ec55fd82b380c95acdf46ba7`。报告固定声明 + `linux_virtualized_live_contract/physicalDevice:false`,因此关闭自动化组合缝隙但不冒充固定型号路由器报告,ADR-0372 继续保持 + Proposed。实现只增强 artifact auditor 的可选临时输出并新增 scripts/test/CI,不新增 package、生产依赖、migration、表、默认 listener、 + timer、watcher、连接、cache 或 sidecar;默认 API-excluded Edge/Standalone 产品闭包不变。完整 backend 为 1,180 tests、1,178 pass/2 + conditional skip/0 fail,完整 18-package clean build/test 退出 0;package/dependency/local-image boundary 全绿,仍无 single-source/shallow + package。14 个 Profile artifact 全部 compatible,最小 Edge 为 2,467,343 bytes/295 files/53 modules,最重 Standalone MCP 为 + 7,168,978 bytes/778 files/213 modules,RSS delta 38,158,336 bytes,均低于各自预算。PostgreSQL 18.4 arm64 HA 干净重跑通过 123 gates、 + timeline `1→2`,报告 SHA-256 `4bf01be43b6eaa0bb6b2d5a2510e6a701c7d02a0fe4a0f246e207cc2c63dc003`。 - D-298/ADR-0386(已接受) Cluster `run.retry | run.stop` 已建立人工触发的真实三节点 Kubernetes 组合门:1 control-plane + 2 worker K3s/Flannel、3 实例 CloudNativePG 1.30.0/PostgreSQL 18.4、2 个跨节点 Run manager Pod、TLS 1.3 mTLS、purpose-bound OIDC strong User、identity diff --git a/docs/adr/ADR-0372-two-phase-authenticated-local-run-cancellation-api.md b/docs/adr/ADR-0372-two-phase-authenticated-local-run-cancellation-api.md index b1018b9d..1e1218a8 100644 --- a/docs/adr/ADR-0372-two-phase-authenticated-local-run-cancellation-api.md +++ b/docs/adr/ADR-0372-two-phase-authenticated-local-run-cancellation-api.md @@ -35,7 +35,7 @@ D-283 后,Local `/api/v3` 已能按 Project 读取 Run、列表、RunEvent 与 - Runtime Core 覆盖 profile-neutral canonical schema、严格 body/result、compat export 与非法状态; - Local admission/transport 覆盖认证与持久审计早于 body、512-byte hard cap、content-type/content-length/UTF-8/JSON 严格性、GET 零 body parser; - SQLite repository 覆盖 accepted、response-loss replay、already-requested、already-terminal、跨 Project、Role revoke/version drift、counter overflow、Event collision、rollback 与 bounded authority queue; -- 真实 SQLite HTTP 覆盖 Owner/Operator allow、Viewer deny、credential confirm、durable Run/Event、取消 lifecycle 收敛与重启后可观察状态; +- 真实 SQLite HTTP 覆盖 Owner/Operator allow、Viewer deny、credential confirm、durable Run/Event、取消 lifecycle 收敛与重启后可观察状态;Linux 虚拟化组合门必须在同一进程链证明 API→intent→真实 PID stop,固定物理设备报告仍独立采集; - Cluster canonical schema、完整源码、制品、Local image 与 PostgreSQL HA 门全绿,且 MCP Tool 清单和默认 Edge/Standalone import closure 不获得写 authority。 ## 当前验证证据 @@ -45,4 +45,4 @@ D-283 后,Local `/api/v3` 已能按 Project 读取 Run、列表、RunEvent 与 - 14 个 Profile artifact 全部 compatible。默认 Edge/Standalone 仅包含 SQLite/Runtime Core/SemVer,为 3,694,042/3,694,096 bytes、375 files、50 loaded modules;API 组合为 5,113,425/5,113,569 bytes。最紧 Application+AI 为 6,281,428/6,281,560 bytes,距 6 MiB 只余 10,028/9,896 bytes,后续增量必须先恢复包内可达文件裁剪余量,不得提高 cap。 - AI/API-excluded arm64 Local image 为 478 files/4,717,459 bytes;Edge 128 MiB/64 PIDs 与 Standalone 256 MiB/256 PIDs 均在只读根、无网络、非 root 条件下 active→graceful stop,SQLite integrity 为 `ok`。 - PostgreSQL 18.4 arm64 HA 112/112 gates、timeline 1→2;私有报告 SHA-256 为 `8416a26aa6220210961a40e22aec897215a55e59423ad48638ca203a8cb488e6`,离线审计 `compatible=true/findings=[]`,Docker 资源零残留。 -- HTTP→SQLite durable intent/exact replay、重启前 cancellation intent 收敛,以及 Linux `/proc` 真实进程 stop 已由相邻集成门分别证明;固定型号低配路由器上的同一 API→进程 stop 链尚无物理报告,因此本 ADR 保持 Proposed。 +- D-299/ADR-0387 已把相邻证据收敛为同一真实 arm64 Linux 链:可选 `edge/standalone-application-api` 制品经 HTTP `task.start` 拉起真实子进程,再经 HTTP cancellation 写入唯一 durable intent/Event,exact replay 不重复,execution-control 收敛 `run/attempt=cancelled`,`/proc/` 的精确 start identity 消失,重启后仍可读 cancelled。Edge 128 MiB/64 PIDs 与 Standalone 256 MiB/256 PIDs 报告均通过独立离线审计;两份报告明确标记 `physicalDevice:false`,因此不能替代固定型号低配路由器上的物理报告,本 ADR 保持 Proposed。 diff --git a/docs/adr/ADR-0387-local-api-cancellation-linux-live-evidence.md b/docs/adr/ADR-0387-local-api-cancellation-linux-live-evidence.md new file mode 100644 index 00000000..b5de8fb1 --- /dev/null +++ b/docs/adr/ADR-0387-local-api-cancellation-linux-live-evidence.md @@ -0,0 +1,43 @@ +# ADR-0387:Local API Cancellation Linux 组合实证门 + +- 状态:Accepted +- 日期:2026-08-12 +- 关联 RFC:QL-RFC-0001 D-299 +- 前置决策:ADR-0364、ADR-0366、ADR-0367、ADR-0372、ADR-0373、ADR-0376、ADR-0380 + +## 上下文 + +ADR-0372 已分别证明认证 HTTP 写入 SQLite durable cancellation intent、execution-control 收敛和 Linux `/proc` 子进程退出,但不同测试之间仍存在组合缝隙:HTTP 使用的 Run 可能不是 runtime 实际拉起的 Run,进程 stop 也可能来自命令文件或测试内直接调用,而非同一个 API mutation。 + +部署用户同时包含 128 MiB 路由设备和 Standalone 主机。默认 Local image 刻意排除 API,不能为了取证把 HTTP listener、认证依赖与 Owner Pepper provider 强塞进所有低配部署;但可选 `edge-application-api | standalone-application-api` 制品已经定义了单进程产品闭包。因此组合门应验证可选 API 制品,同时保持默认 Edge/Standalone 零增量。 + +## 决策 + +1. 新增 opt-in `ql3-local-api-cancellation-live-contract`。它只接受 fresh absolute `0600` report path,并要求显式 `QL3_LOCAL_API_CANCELLATION_LIVE=1` 后才允许调用 Docker。 +2. live gate 复用 `ql3-local-profile-artifact-audit` 的离线 pack、精确 import closure 与 runtime JavaScript pruning;auditor 新增 `--output-directory`,只允许复制一个此前不存在的 absolute 目录,不改变默认 stdout schema、预算或已有 14 个 profile 行为。 +3. 每个 profile 在 `node:24.18.0-bookworm-slim` 锁定镜像内以当前非 root UID、read-only root、network none、capabilities none、no-new-privileges、0.5 CPU、16 MiB noexec tmpfs 运行;Edge 固定 128 MiB/64 PIDs,Standalone 固定 256 MiB/256 PIDs。 +4. 同一 Local API 进程经真实 loopback HTTP `task.start` 创建并调度一个长期 command Run。gate 从 SQLite 读取 runtime 持久化的 PID,并从 `/proc//stat` 绑定 start ticks;随后真实 HTTP `run.cancel` 必须得到 `accepted → already_requested`。 +5. gate 必须观察唯一 `run.cancel_requested`、唯一 `run.cancelled`、两条 allowed audit、Run/Attempt 均 cancelled、精确 PID/start identity 消失、SQLite integrity `ok`;Local API 有序停止并重启后,同一 Run 仍必须通过 HTTP 观察为 cancelled。 +6. 私有 report 只保存 profile、平台、resource envelope、artifact size/files/modules 和低敏布尔/计数事实;不保存 token、Pepper、路径、命令、Run ID、PID 或业务内容。独立 audit 对缺失 replay、PID exit、durability、资源预算或 qualification 的报告失败关闭。 +7. report 的资格固定为 `linux_virtualized_live_contract`、`physicalDevice:false`。它关闭自动化组合缝隙,但不伪装成固定型号路由器证据,不能单独把 ADR-0372 转为 Accepted。 +8. 不新增 workspace package、生产依赖、migration、表、索引、listener、timer、watcher、连接、cache、sidecar 或默认产品能力。代码放在现有 `scripts/` live/audit 分层中,不制造只有一个文件的微包。 + +## Package 与低配影响 + +workspace 仍为 18 个领域 package。新增文件都是发布/证据脚本,不进入任何 runtime artifact;默认 Edge/Standalone image 仍不包含 API。可选 API 制品的 Edge/Standalone 实测为 3,668,052/3,668,196 bytes、429 files、85 loaded modules,分别距 6 MiB 上限保留 2,623,404/2,623,260 bytes。真实运行 API RSS 为 80,736,256/78,868,480 bytes,均低于对应 128/256 MiB envelope。 + +## 验收状态 + +- report validator 与 opt-in/no-overwrite 失败关闭测试 3/3 通过;脚本均通过 Node syntax check。 +- 完整 backend 回归 1,180 tests、1,178 pass/2 conditional skip/0 fail;完整 18-package clean build/test 退出 0。 +- package boundary 保持 18 个 workspace package,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;dependency 与 Local image static audit 均为 `compatible:true/findings:[]`。 +- 14 个 Edge/Standalone Profile artifact 全部 compatible。最小 Edge 为 2,467,343 bytes/295 files/53 loaded modules,RSS delta 11,157,504 bytes;最重 Standalone MCP 为 7,168,978 bytes/778 files/213 loaded modules,RSS delta 38,158,336 bytes,均低于各自预算。 +- arm64 Linux Edge 组合门通过:task start/cancellation/exact replay、durable intent/cancelled Event、两条 allowed audit、PID/start identity exit、重启观察、SQLite integrity 全绿;私有报告权限 `0600`,SHA-256 `056f8f1c07f0c5dfe4552fcb605d6b55b194cd826dbf1c720b21fdba4bd55e53`,离线审计零 finding。 +- arm64 Linux Standalone 组合门同样通过;私有报告权限 `0600`,SHA-256 `223b7241ec3af8edea824dd802f24d573d0987c2ec55fd82b380c95acdf46ba7`,离线审计零 finding。 +- PostgreSQL 18.4 arm64 HA 干净重跑通过 123 gates、timeline `1→2`;报告 SHA-256 `4bf01be43b6eaa0bb6b2d5a2510e6a701c7d02a0fe4a0f246e207cc2c63dc003`。 +- CI 在 local image 的 amd64/arm64 matrix 中分别执行 Edge 与 Standalone gate;live 资源为短生命周期,完成后 artifact/evidence 临时目录和容器被清理。 +- 固定型号物理 Edge 设备尚未产生 `physicalDevice:true` 的独立资格报告;ADR-0372 因此继续保持 Proposed。 + +## 后果 + +Local cancellation 的自动化证据不再依赖“两个相邻测试看起来可以拼接”的推断,路由器与 Standalone profile 都有同构、可复现的真实 Linux 子进程门。代价是 local-image CI 每个架构额外组装两次可选 API 制品;这是 release evidence 成本,不进入产品常驻面。后续物理 recorder 应复用相同低敏事实和 PID/start identity 语义,但必须增加设备 manifest、反虚拟化检查与签名/导入流程,不能修改本报告的 `physicalDevice:false`。 diff --git a/docs/adr/README.md b/docs/adr/README.md index f1c9ed78..5107289c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -375,7 +375,7 @@ | [ADR-0369](./ADR-0369-profile-exact-runtime-export-projection.md) | Profile 精确 Runtime Export 投影 | Accepted | | [ADR-0370](./ADR-0370-bounded-project-run-event-timeline-http-api.md) | 有界、Project-scoped 的 RunEvent 时间线 HTTP API | Accepted | | [ADR-0371](./ADR-0371-bounded-project-run-step-list-http-and-mcp-api.md) | 有界、Project-scoped 的 Run StepRun HTTP 与 MCP API | Accepted | -| [ADR-0372](./ADR-0372-two-phase-authenticated-local-run-cancellation-api.md) | 两阶段认证的 Local Run Cancellation API | Proposed(实现完成,固定实机 API/进程 stop 门待补) | +| [ADR-0372](./ADR-0372-two-phase-authenticated-local-run-cancellation-api.md) | 两阶段认证的 Local Run Cancellation API | Proposed(Linux 组合门已完成,固定物理设备报告待补) | | [ADR-0373](./ADR-0373-profile-reachable-runtime-javascript-projection.md) | Profile 可达的 Runtime JavaScript 投影 | Accepted | | [ADR-0374](./ADR-0374-shared-bounded-task-discovery-http-api.md) | 共享、有界的 Task Discovery HTTP API | Accepted | | [ADR-0375](./ADR-0375-shared-current-task-point-read-api.md) | 共享的 current Task point-read API | Proposed(设计冻结,实现中) | @@ -390,6 +390,7 @@ | [ADR-0384](./ADR-0384-strong-cluster-run-stop-management.md) | 强认证的 Cluster Run Stop Management 与列级数据库权限 | Accepted | | [ADR-0385](./ADR-0385-strong-local-run-stop-product-entry.md) | 强认证的 Local Run Stop 产品入口与原子审计 | Accepted | | [ADR-0386](./ADR-0386-cluster-run-management-kubernetes-live-evidence.md) | Cluster Run Management Kubernetes 多节点实证 | Accepted | +| [ADR-0387](./ADR-0387-local-api-cancellation-linux-live-evidence.md) | Local API Cancellation Linux 组合实证门 | Accepted | ## 规则 diff --git a/package.json b/package.json index 5d834091..8c863246 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "audit:approval-management-kubernetes-live:ql3": "node scripts/ql3-approval-management-kubernetes-live-audit.cjs", "test:run-management-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && pnpm --filter @qinglong/cluster-control build && node scripts/ql3-run-management-kubernetes-live-contract.cjs", "audit:run-management-kubernetes-live:ql3": "node scripts/ql3-run-management-kubernetes-live-audit.cjs", + "test:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-contract.cjs", + "audit:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-audit.cjs", "test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs", "audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs", "test:prompt-output-key-retirement-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-prompt-output-key-retirement-kubernetes-live-contract.cjs", diff --git a/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs b/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs new file mode 100644 index 00000000..e1502a27 --- /dev/null +++ b/scripts/lib/ql3-local-api-cancellation-live-scenario.cjs @@ -0,0 +1,706 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { spawn } = require('node:child_process'); +const { DatabaseSync } = require('node:sqlite'); + +const TIMEOUT_MS = 45_000; + +function fail(message) { + throw new Error( + `QingLong Local API cancellation scenario failed: ${message}`, + ); +} + +function options(argv) { + if (argv.length !== 3) + fail('usage: artifact-root evidence-root edge|standalone'); + const [artifactRoot, evidenceRoot, profile] = argv; + for (const [value, label] of [ + [artifactRoot, 'artifact root'], + [evidenceRoot, 'evidence root'], + ]) { + if (!path.isAbsolute(value) || path.normalize(value) !== value) { + fail(`${label} must be absolute and normalized`); + } + } + if (!['edge', 'standalone'].includes(profile)) fail('profile is invalid'); + return Object.freeze({ artifactRoot, evidenceRoot, profile }); +} + +function privateDirectory(directory) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); + const stat = fs.lstatSync(directory); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.mode & 0o777) !== 0o700 + ) { + fail(`private directory is invalid: ${directory}`); + } +} + +function privateJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { + flag: 'wx', + mode: 0o600, + }); +} + +function procStartTicks(pid) { + const fields = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim().split(' '); + const ticks = fields[21]; + if (!/^[1-9][0-9]*$/.test(ticks ?? '')) + fail('process start ticks are invalid'); + return ticks; +} + +function sameProcessExists(pid, startTicks) { + try { + return procStartTicks(pid) === startTicks; + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ESRCH') return false; + throw error; + } +} + +function rssBytes(pid) { + const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8'); + const match = /^VmRSS:\s+(\d+) kB$/m.exec(status); + if (!match) fail('process RSS is unavailable'); + return Number(match[1]) * 1024; +} + +function request(port, token, requestPath, values = {}) { + return new Promise((resolve, reject) => { + const outgoing = http.request( + { + host: '127.0.0.1', + port, + path: requestPath, + method: values.method ?? 'GET', + headers: { + authorization: `Bearer ${token}`, + connection: 'close', + ...(values.headers ?? {}), + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + try { + resolve({ + statusCode: response.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + }); + } catch (error) { + reject(error); + } + }); + }, + ); + outgoing.once('error', reject); + if (values.body !== undefined) outgoing.write(values.body); + outgoing.end(); + }); +} + +function reservePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close((error) => { + if (error) reject(error); + else if (!address || typeof address === 'string') { + reject(new Error('dynamic loopback port is unavailable')); + } else resolve(address.port); + }); + }); + }); +} + +async function waitFor(probe, label) { + const deadline = Date.now() + TIMEOUT_MS; + let lastError; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value) return value; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + fail(`${label} did not converge${lastError ? `: ${lastError.message}` : ''}`); +} + +function query(databasePath, sql, ...parameters) { + const database = new DatabaseSync(databasePath, { + readOnly: true, + timeout: 100, + }); + try { + return database.prepare(sql).get(...parameters); + } finally { + database.close(); + } +} + +function insertTaskAndIdentity( + modules, + databasePath, + pepperKeyId, + pepper, + now, +) { + const { + apiCredentialSecretDigest, + createBuiltInTaskSpecSemanticRegistry, + createTaskDefinitionRecord, + compileLocalCommandTaskDefinition, + formatApiCredentialToken, + } = modules; + const credentialId = 'local-live-operator'; + const subjectId = 'local-live-user'; + const secret = crypto.randomBytes(32).toString('base64url'); + const taskSemantics = createBuiltInTaskSpecSemanticRegistry(); + const taskCommand = { + projectId: 'default', + taskId: 'live-cancellation-task', + expectedRevision: null, + mutationId: '019f8700-0000-7000-8000-000000000001', + name: 'Local API cancellation live task', + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { + command: { + kind: 'argv', + file: '/bin/sh', + args: ['-c', 'trap "exit 0" TERM INT; while :; do sleep 1; done'], + }, + }, + }, + labels: {}, + enabled: true, + occurredAtMs: now, + }; + const definition = createTaskDefinitionRecord( + { + ...taskCommand, + spec: taskSemantics.normalize({ + projectId: taskCommand.projectId, + taskId: taskCommand.taskId, + kind: taskCommand.kind, + spec: taskCommand.spec, + }), + }, + now, + ); + const execution = compileLocalCommandTaskDefinition( + definition, + taskSemantics, + ); + const database = new DatabaseSync(databasePath, { timeout: 100 }); + try { + database.exec('PRAGMA foreign_keys = ON; BEGIN IMMEDIATE'); + database + .prepare( + `INSERT INTO "QingLong3IdentitySubjects" ( + "subject_type", "subject_id", "status", "version", + "created_at_ms", "updated_at_ms" + ) VALUES ('user', ?, 'active', 1, ?, ?)`, + ) + .run(subjectId, now, now); + database + .prepare( + `INSERT INTO "QingLong3ApiCredentials" ( + "credential_id", "version", "state", "subject_type", "subject_id", + "secret_digest", "created_at_ms", "not_before_at_ms", "expires_at_ms" + ) VALUES (?, 1, 'active', 'user', ?, ?, ?, ?, ?)`, + ) + .run( + credentialId, + subjectId, + apiCredentialSecretDigest(pepper, credentialId, secret), + now, + now, + now + 3_600_000, + ); + database + .prepare( + `INSERT INTO "QingLong3ApiCredentialPepperBindings" ( + "credential_id", "credential_version", "pepper_key_id" + ) VALUES (?, 1, ?)`, + ) + .run(credentialId, pepperKeyId); + database + .prepare( + `INSERT INTO "QingLong3ProjectRoleBindings" ( + "project_id", "subject_type", "subject_id", "version", "state", + "role", "mutation_id", "changed_by_type", "changed_by_id", + "created_at_ms" + ) VALUES ('default', 'user', ?, 1, 'active', 'operator', ?, 'system', + 'local-live-gate', ?)`, + ) + .run(subjectId, 'local-live-role-binding', now); + database + .prepare( + `INSERT INTO "QingLong3TaskDefinitions" ( + "project_id", "task_id", "current_revision", "created_at_ms", + "updated_at_ms" + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run( + definition.projectId, + definition.taskId, + definition.revision, + now, + now, + ); + database + .prepare( + `INSERT INTO "QingLong3TaskDefinitionRevisions" ( + "project_id", "task_id", "revision", "mutation_id", "name", + "description", "kind", "spec_json", "labels_json", "enabled", + "content_digest", "created_at_ms" + ) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, 1, ?, ?)`, + ) + .run( + definition.projectId, + definition.taskId, + definition.revision, + definition.mutationId, + definition.name, + definition.kind, + JSON.stringify(definition.spec), + JSON.stringify(definition.labels), + definition.contentDigest, + now, + ); + database + .prepare( + `INSERT INTO "QingLong3LocalExecutionContextRecipes" ( + "context_ref", "environment_json", "content_digest", "created_at_ms" + ) VALUES (?, ?, ?, ?)`, + ) + .run( + execution.contextRecipe.contextRef, + JSON.stringify(execution.contextRecipe.environment), + execution.contextRecipe.contentDigest, + execution.contextRecipe.createdAtMs, + ); + database + .prepare( + `INSERT INTO "QingLong3LocalTaskExecutionRevisions" ( + "project_id", "task_id", "task_revision", "executor_type", + "command_json", "working_directory", "timeout_ms", "context_ref", + "content_digest", "created_at_ms" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + execution.executionRevision.projectId, + execution.executionRevision.taskId, + execution.executionRevision.taskRevision, + execution.executionRevision.executorType, + JSON.stringify(execution.executionRevision.command), + execution.executionRevision.workingDirectory ?? null, + execution.executionRevision.timeoutMs ?? null, + execution.executionRevision.contextRef, + execution.executionRevision.contentDigest, + execution.executionRevision.createdAtMs, + ); + database.exec('COMMIT'); + } catch (error) { + if (database.isTransaction) database.exec('ROLLBACK'); + throw error; + } finally { + database.close(); + } + return Object.freeze({ + definition, + token: formatApiCredentialToken(credentialId, secret), + }); +} + +function startApi(executable, configPath) { + const child = spawn(process.execPath, [executable, '--config', configPath], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'production' }, + }); + const events = []; + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + while (stdout.includes('\n')) { + const index = stdout.indexOf('\n'); + const line = stdout.slice(0, index); + stdout = stdout.slice(index + 1); + if (line) events.push(JSON.parse(line)); + } + }); + child.stderr.on('data', (chunk) => { + stderr = (stderr + chunk).slice(-16_384); + }); + const exit = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + return Object.freeze({ child, events, exit, stderr: () => stderr }); +} + +async function stopApi(active) { + active.child.kill('SIGTERM'); + const outcome = await Promise.race([ + active.exit, + new Promise((_, reject) => + setTimeout(() => reject(new Error('API stop timeout')), 30_000), + ), + ]); + if (outcome.code !== 0 || outcome.signal !== null) { + fail( + `API process did not stop cleanly: ${JSON.stringify({ + ...outcome, + stderr: active.stderr(), + })}`, + ); + } + return outcome; +} + +async function main(argv = process.argv.slice(2)) { + const value = options(argv); + if (process.platform !== 'linux' || !fs.existsSync('/proc/self/stat')) { + fail('a real Linux /proc runtime is required'); + } + privateDirectory(value.evidenceRoot); + const deploymentRoot = path.join(value.evidenceRoot, 'deployment'); + for (const directory of [ + deploymentRoot, + path.join(deploymentRoot, 'owner-peppers'), + path.join(deploymentRoot, 'receipts'), + path.join(deploymentRoot, 'artifacts'), + path.join(deploymentRoot, 'plugin-staging'), + path.join(deploymentRoot, 'plugin-activation'), + ]) + privateDirectory(directory); + + const artifactRequire = createRequire( + path.join(value.artifactRoot, 'package.json'), + ); + const { migrateLocalSqlitePath } = artifactRequire( + '@qinglong/local-sqlite/migration', + ); + const { LocalOwnerPepperKeyringFileProvider, provisionLocalOwnerPepperKey } = + artifactRequire('@qinglong/local-owner-console/pepper-custody'); + const { provisionLocalSecretKeyring } = artifactRequire( + '@qinglong/local-secret', + ); + const tokenModule = artifactRequire( + '@qinglong/runtime-core/api-credential-token', + ); + const definitionModule = artifactRequire( + '@qinglong/runtime-core/task-definition', + ); + const compilerModule = artifactRequire( + '@qinglong/runtime-core/task-definition-execution-compiler', + ); + const semanticModule = artifactRequire( + '@qinglong/runtime-core/task-spec-semantic', + ); + const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite'); + const pepperKeyId = 'owner-v1'; + const now = Date.now(); + const pepperSummary = provisionLocalOwnerPepperKey({ + keyringDirectory: path.join(deploymentRoot, 'owner-peppers'), + pepperKeyId, + }); + await migrateLocalSqlitePath({ databasePath, profile: value.profile }); + await provisionLocalSecretKeyring( + path.join(deploymentRoot, 'local-secret-keyring.json'), + ); + const database = new DatabaseSync(databasePath); + try { + database + .prepare( + `INSERT INTO "QingLong3LocalOwnerPepperKeys" ( + "pepper_key_id", "material_digest", "backup_digest", "state", + "version", "register_mutation_id", "activate_mutation_id", + "registered_at_ms", "activated_at_ms" + ) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`, + ) + .run( + pepperKeyId, + pepperSummary.digest, + 'b'.repeat(64), + '019f8700-0000-4000-8000-000000000002', + '019f8700-0000-4000-8000-000000000003', + now, + now, + ); + database + .prepare( + `INSERT INTO "QingLong3LocalOwnerPepperActivations" ( + "generation", "mutation_id", "expected_generation", + "previous_pepper_key_id", "active_pepper_key_id", "material_digest", + "backup_digest", "activated_at_ms" + ) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`, + ) + .run( + '019f8700-0000-4000-8000-000000000003', + pepperKeyId, + pepperSummary.digest, + 'b'.repeat(64), + now, + ); + } finally { + database.close(); + } + const pepper = new LocalOwnerPepperKeyringFileProvider( + path.join(deploymentRoot, 'owner-peppers'), + ).resolve(pepperKeyId).pepper; + const seeded = insertTaskAndIdentity( + { + ...tokenModule, + ...definitionModule, + ...compilerModule, + ...semanticModule, + }, + databasePath, + pepperKeyId, + pepper, + now, + ); + const port = await reservePort(); + const applicationConfigPath = path.join( + deploymentRoot, + 'local-application.json', + ); + const apiConfigPath = path.join(deploymentRoot, 'local-api.json'); + privateJson(applicationConfigPath, { + schema: 'qinglong/local-application-process@v2', + instanceId: `local-api-cancellation-${value.profile}`, + profile: value.profile, + storage: { mode: 'fresh', databasePath, busyTimeoutMs: 100 }, + runtime: { + receiptRoot: path.join(deploymentRoot, 'receipts'), + artifactRoot: path.join(deploymentRoot, 'artifacts'), + secretKeyringPath: path.join(deploymentRoot, 'local-secret-keyring.json'), + }, + pluginPackages: { + stagingRoot: path.join(deploymentRoot, 'plugin-staging'), + activationRoot: path.join(deploymentRoot, 'plugin-activation'), + recoverySource: { mode: 'disabled' }, + pageSize: value.profile === 'edge' ? 4 : 16, + maxPages: 1, + taskPublicationPageSize: value.profile === 'edge' ? 4 : 16, + taskPublicationMaxPages: 1, + }, + ai: { deployment: 'excluded' }, + }); + privateJson(apiConfigPath, { + schema: 'qinglong/local-api-process@v1', + deploymentRoot, + applicationConfigFilePath: applicationConfigPath, + ownerPepperKeyringDirectory: path.join(deploymentRoot, 'owner-peppers'), + listener: { host: '127.0.0.1', port }, + }); + const executable = path.join( + value.artifactRoot, + 'node_modules/@qinglong/local-api/dist/cli.js', + ); + let active = startApi(executable, apiConfigPath); + try { + await waitFor( + () => active.events.some((event) => event.event === 'listening'), + 'Local API listener', + ); + const startBody = JSON.stringify({ + schema: 'qinglong/task-start@v1', + mutationId: '019f8700-0000-7000-8000-000000000004', + expectedRevision: seeded.definition.revision, + expectedContentDigest: seeded.definition.contentDigest, + }); + const started = await request( + port, + seeded.token, + '/api/v3/projects/default/tasks/live-cancellation-task/runs', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(startBody)), + }, + body: startBody, + }, + ); + if (started.statusCode !== 202 || started.body.status !== 'accepted') { + fail(`task start was rejected: ${JSON.stringify(started)}`); + } + const running = await waitFor(() => { + const row = query( + databasePath, + `SELECT run.status, attempt.status AS attemptStatus, attempt.pid + FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id + WHERE run.id = ?`, + started.body.runId, + ); + return row?.status === 'running' && + row?.attemptStatus === 'running' && + row?.pid + ? row + : null; + }, 'task process start'); + const taskPid = Number(running.pid); + const taskStartTicks = procStartTicks(taskPid); + const apiRssBytes = rssBytes(active.child.pid); + const cancellationBody = JSON.stringify({ + schema: 'qinglong/run-cancellation@v1', + mutationId: 'local-live-cancellation-1', + }); + const cancellationPath = `/api/v3/projects/default/runs/${started.body.runId}/cancellation`; + const requestOptions = { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(cancellationBody)), + }, + body: cancellationBody, + }; + const accepted = await request( + port, + seeded.token, + cancellationPath, + requestOptions, + ); + const replay = await request( + port, + seeded.token, + cancellationPath, + requestOptions, + ); + if (accepted.statusCode !== 202 || accepted.body.status !== 'accepted') { + fail(`cancellation was rejected: ${JSON.stringify(accepted)}`); + } + if ( + replay.statusCode !== 200 || + replay.body.status !== 'already_requested' + ) { + fail(`cancellation replay drifted: ${JSON.stringify(replay)}`); + } + const terminal = await waitFor(() => { + const row = query( + databasePath, + `SELECT run.status, attempt.status AS attemptStatus + FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id + WHERE run.id = ?`, + started.body.runId, + ); + return row?.status === 'cancelled' && row?.attemptStatus === 'cancelled' + ? row + : null; + }, 'durable cancellation'); + await waitFor( + () => !sameProcessExists(taskPid, taskStartTicks), + 'task process identity exit', + ); + await stopApi(active); + active = startApi(executable, apiConfigPath); + await waitFor( + () => active.events.some((event) => event.event === 'listening'), + 'restarted Local API listener', + ); + const observed = await request( + port, + seeded.token, + `/api/v3/projects/default/runs/${started.body.runId}`, + ); + if ( + observed.statusCode !== 200 || + observed.body.run.status !== 'cancelled' + ) { + fail(`restart observation drifted: ${JSON.stringify(observed)}`); + } + await stopApi(active); + const facts = query( + databasePath, + `SELECT + (SELECT COUNT(*) FROM RunEvents WHERE run_id = ? AND type = 'run.cancel_requested') AS cancelEvents, + (SELECT COUNT(*) FROM RunEvents WHERE run_id = ? AND type = 'run.cancelled') AS cancelledEvents, + (SELECT COUNT(*) FROM QingLong3SecurityAuditEvents WHERE operation_id = 'run.cancel' AND outcome = 'allowed') AS cancelAudits, + (SELECT integrity_check FROM pragma_integrity_check LIMIT 1) AS integrity`, + started.body.runId, + started.body.runId, + ); + if ( + facts.cancelEvents !== 1 || + facts.cancelledEvents !== 1 || + facts.cancelAudits !== 2 || + facts.integrity !== 'ok' + ) + fail(`durable facts drifted: ${JSON.stringify(facts)}`); + const report = { + schemaVersion: 1, + profile: value.profile, + platform: { os: 'linux', architecture: process.arch, procfs: true }, + resourceEnvelope: { + memoryBytes: + value.profile === 'edge' ? 128 * 1024 * 1024 : 256 * 1024 * 1024, + pids: value.profile === 'edge' ? 64 : 256, + apiRssBytes, + }, + observations: { + taskStartAccepted: true, + cancellationAccepted: true, + exactReplay: true, + durableIntentEvents: facts.cancelEvents, + durableCancellationEvents: facts.cancelledEvents, + durableAllowedAudits: facts.cancelAudits, + processIdentityObserved: true, + processIdentityGone: !sameProcessExists(taskPid, taskStartTicks), + restartObservedCancelled: true, + sqliteIntegrity: facts.integrity, + }, + qualification: { + evidenceClass: 'linux_virtualized_live_contract', + physicalDevice: false, + passed: true, + }, + compatible: terminal.status === 'cancelled', + }; + privateJson(path.join(value.evidenceRoot, 'report.json'), report); + process.stdout.write(`${JSON.stringify(report)}\n`); + } finally { + if (active.child.exitCode === null && active.child.signalCode === null) { + active.child.kill('SIGKILL'); + await active.exit.catch(() => undefined); + } + } +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `${ + error instanceof Error ? error.stack || error.message : String(error) + }\n`, + ); + process.exitCode = 1; + }); +} + +module.exports = { options }; diff --git a/scripts/ql3-local-api-cancellation-live-audit.cjs b/scripts/ql3-local-api-cancellation-live-audit.cjs new file mode 100644 index 00000000..765970ed --- /dev/null +++ b/scripts/ql3-local-api-cancellation-live-audit.cjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const PROFILES = new Set(['edge', 'standalone']); + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...expected].sort()) + ); +} + +function validateLocalApiCancellationLiveReport(value) { + const findings = []; + const record = exactKeys(value, [ + 'artifact', + 'compatible', + 'observations', + 'platform', + 'profile', + 'qualification', + 'resourceEnvelope', + 'schemaVersion', + ]); + if (!record || value.schemaVersion !== 1 || !PROFILES.has(value.profile)) { + return Object.freeze({ + compatible: false, + findings: ['report identity is invalid'], + }); + } + const expectedMemory = + value.profile === 'edge' ? 128 * 1024 * 1024 : 256 * 1024 * 1024; + const expectedPids = value.profile === 'edge' ? 64 : 256; + if ( + !exactKeys(value.platform, ['architecture', 'os', 'procfs']) || + value.platform?.os !== 'linux' || + !['arm64', 'x64'].includes(value.platform?.architecture) || + value.platform?.procfs !== true + ) + findings.push('Linux /proc platform observation is invalid'); + if ( + !exactKeys(value.resourceEnvelope, [ + 'apiRssBytes', + 'memoryBytes', + 'pids', + ]) || + value.resourceEnvelope?.memoryBytes !== expectedMemory || + value.resourceEnvelope?.pids !== expectedPids || + !Number.isSafeInteger(value.resourceEnvelope?.apiRssBytes) || + value.resourceEnvelope.apiRssBytes < 1 || + value.resourceEnvelope.apiRssBytes > expectedMemory + ) + findings.push('resource envelope is invalid'); + if ( + !exactKeys(value.artifact, [ + 'bytes', + 'compatible', + 'files', + 'loadedModules', + 'profile', + ]) || + value.artifact?.profile !== `${value.profile}-application-api` || + !Number.isSafeInteger(value.artifact?.bytes) || + value.artifact.bytes < 1 || + value.artifact.bytes > 6 * 1024 * 1024 || + !Number.isSafeInteger(value.artifact?.files) || + value.artifact.files < 1 || + value.artifact.files > 640 || + !Number.isSafeInteger(value.artifact?.loadedModules) || + value.artifact.loadedModules < 1 || + value.artifact.loadedModules > 256 || + value.artifact.compatible !== true + ) + findings.push('optional API artifact evidence is invalid'); + const observed = value.observations; + if ( + !exactKeys(observed, [ + 'cancellationAccepted', + 'durableAllowedAudits', + 'durableCancellationEvents', + 'durableIntentEvents', + 'exactReplay', + 'processIdentityGone', + 'processIdentityObserved', + 'restartObservedCancelled', + 'sqliteIntegrity', + 'taskStartAccepted', + ]) || + observed?.taskStartAccepted !== true || + observed?.cancellationAccepted !== true || + observed?.exactReplay !== true || + observed?.durableIntentEvents !== 1 || + observed?.durableCancellationEvents !== 1 || + observed?.durableAllowedAudits !== 2 || + observed?.processIdentityObserved !== true || + observed?.processIdentityGone !== true || + observed?.restartObservedCancelled !== true || + observed?.sqliteIntegrity !== 'ok' + ) + findings.push('API to durable process-stop observations are incomplete'); + if ( + !exactKeys(value.qualification, [ + 'evidenceClass', + 'passed', + 'physicalDevice', + ]) || + value.qualification?.evidenceClass !== 'linux_virtualized_live_contract' || + value.qualification?.physicalDevice !== false || + value.qualification?.passed !== true + ) + findings.push('qualification boundary is invalid'); + if (value.compatible !== true) findings.push('report is not compatible'); + return Object.freeze({ + compatible: findings.length === 0, + findings: Object.freeze(findings), + }); +} + +function reportPath(argv) { + if ( + argv.length !== 1 || + !argv[0].startsWith('--report=') || + !path.isAbsolute(argv[0].slice('--report='.length)) + ) + throw new Error( + 'usage: ql3-local-api-cancellation-live-audit --report=/absolute/private-report.json', + ); + return argv[0].slice('--report='.length); +} + +if (require.main === module) { + try { + const filePath = reportPath(process.argv.slice(2)); + const stat = fs.lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + (typeof process.getuid === 'function' && stat.uid !== process.getuid()) || + (stat.mode & 0o077) !== 0 || + stat.size > 32 * 1024 + ) { + throw new Error('report must be a private bounded regular file'); + } + const audit = validateLocalApiCancellationLiveReport( + JSON.parse(fs.readFileSync(filePath, 'utf8')), + ); + process.stdout.write(`${JSON.stringify(audit)}\n`); + if (!audit.compatible) process.exitCode = 1; + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} + +module.exports = { validateLocalApiCancellationLiveReport }; diff --git a/scripts/ql3-local-api-cancellation-live-contract.cjs b/scripts/ql3-local-api-cancellation-live-contract.cjs new file mode 100644 index 00000000..53e5ed0d --- /dev/null +++ b/scripts/ql3-local-api-cancellation-live-contract.cjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { + validateLocalApiCancellationLiveReport, +} = require('./ql3-local-api-cancellation-live-audit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const NODE_IMAGE = + 'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d'; + +function fail(message) { + throw new Error( + `QingLong Local API cancellation live contract failed: ${message}`, + ); +} + +function argumentsOf(argv) { + if (argv.length !== 2) + fail( + 'usage: --profile=edge|standalone --report=/absolute/private-report.json', + ); + const values = Object.fromEntries( + argv.map((argument) => { + const match = /^--(profile|report)=(.+)$/.exec(argument); + if (!match) fail(`unsupported argument ${argument}`); + return [match[1], match[2]]; + }), + ); + if (!['edge', 'standalone'].includes(values.profile)) + fail('profile is invalid'); + if ( + !path.isAbsolute(values.report ?? '') || + path.normalize(values.report) !== values.report || + path.parse(values.report).root === values.report || + fs.existsSync(values.report) + ) + fail('report must be a fresh normalized absolute non-root path'); + const parent = fs.lstatSync(path.dirname(values.report)); + if (!parent.isDirectory() || parent.isSymbolicLink()) + fail('report parent must be a real directory'); + return Object.freeze(values); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail( + `${command} ${args[0]} failed: ${(result.stderr || result.stdout) + .trim() + .slice(0, 4096)}`, + ); + } + return result.stdout.trim(); +} + +function main(argv = process.argv.slice(2)) { + const selected = argumentsOf(argv); + if (process.env.QL3_LOCAL_API_CANCELLATION_LIVE !== '1') { + fail('refusing to run Docker without QL3_LOCAL_API_CANCELLATION_LIVE=1'); + } + const temporaryRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-api-cancel-live-')), + ); + fs.chmodSync(temporaryRoot, 0o700); + const artifactRoot = path.join(temporaryRoot, 'artifact'); + const evidenceRoot = path.join(temporaryRoot, 'evidence'); + fs.mkdirSync(evidenceRoot, { mode: 0o700 }); + try { + const artifactOutput = run(process.execPath, [ + path.join(ROOT, 'scripts/ql3-local-profile-artifact-audit.cjs'), + `${selected.profile}-application-api`, + `--output-directory=${artifactRoot}`, + ]); + const artifact = JSON.parse(artifactOutput.split(/\r?\n/).at(-1)); + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + const gid = typeof process.getgid === 'function' ? process.getgid() : null; + if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid)) + fail('a POSIX identity is required'); + const memory = selected.profile === 'edge' ? '128m' : '256m'; + const pids = selected.profile === 'edge' ? '64' : '256'; + run('docker', [ + 'run', + '--rm', + '--read-only', + '--user', + `${uid}:${gid}`, + '--network', + 'none', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--memory', + memory, + '--memory-swap', + memory, + '--cpus', + '0.5', + '--pids-limit', + pids, + '--tmpfs', + '/tmp:rw,nosuid,nodev,noexec,size=16m', + '--volume', + `${artifactRoot}:/opt/ql3-artifact:ro`, + '--volume', + `${path.join(ROOT, 'scripts')}:/opt/ql3-scripts:ro`, + '--volume', + `${evidenceRoot}:/evidence`, + NODE_IMAGE, + 'node', + '/opt/ql3-scripts/lib/ql3-local-api-cancellation-live-scenario.cjs', + '/opt/ql3-artifact', + '/evidence', + selected.profile, + ]); + const scenario = JSON.parse( + fs.readFileSync(path.join(evidenceRoot, 'report.json'), 'utf8'), + ); + const report = Object.freeze({ + ...scenario, + artifact: Object.freeze({ + profile: artifact.profile, + bytes: artifact.artifactBytes, + files: artifact.artifactFiles, + loadedModules: artifact.loadedModuleCount, + compatible: artifact.compatible, + }), + }); + const audit = validateLocalApiCancellationLiveReport(report); + assert.deepEqual(audit.findings, []); + fs.writeFileSync(selected.report, `${JSON.stringify(report, null, 2)}\n`, { + flag: 'wx', + mode: 0o600, + }); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + profile: selected.profile, + reportWritten: true, + compatible: true, + })}\n`, + ); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error ? error.stack || error.message : String(error) + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = { argumentsOf }; diff --git a/scripts/ql3-local-image-audit.cjs b/scripts/ql3-local-image-audit.cjs index e9b62e08..fcd1ca63 100644 --- a/scripts/ql3-local-image-audit.cjs +++ b/scripts/ql3-local-image-audit.cjs @@ -284,8 +284,8 @@ function auditWorkflow(contents, findings) { 'scripts/ql3-local-image-inventory.cjs', '--inventory-root=/opt/qinglong/node_modules', 'node ../../scripts/ql3-build-package-closure.cjs', - '--profile=edge', - '--profile=standalone', + 'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge', + 'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone', ]; for (const value of required) { if (!job.includes(value)) { diff --git a/scripts/ql3-local-profile-artifact-audit.cjs b/scripts/ql3-local-profile-artifact-audit.cjs index 0e46c609..340e4a3c 100644 --- a/scripts/ql3-local-profile-artifact-audit.cjs +++ b/scripts/ql3-local-profile-artifact-audit.cjs @@ -48,6 +48,35 @@ function boundedPositiveInteger(value, fallback, label) { return normalized; } +function outputDirectoryArgument(value) { + if (value === undefined) return undefined; + if ( + value.length < 1 || + Buffer.byteLength(value, 'utf8') > 4_096 || + value.includes('\0') || + !path.isAbsolute(value) || + path.normalize(value) !== value || + path.parse(value).root === value + ) { + fail( + 'output directory must be a normalized bounded absolute non-root path', + ); + } + if (fs.existsSync(value)) { + fail('output directory must not already exist'); + } + const parent = path.dirname(value); + const stat = fs.lstatSync(parent); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(parent) !== parent + ) { + fail('output directory parent must be a canonical real directory'); + } + return value; +} + function parseArguments(argv) { const profile = argv[0]; if ( @@ -70,13 +99,23 @@ function parseArguments(argv) { 'Profile must be edge, standalone, an adopted/application variant, an AI/API variant, or an MCP variant', ); } - const values = Object.fromEntries( - argv.slice(1).map((argument) => { - const match = /^--([a-z-]+)=(\d+)$/.exec(argument); - if (!match) fail(`unsupported argument ${argument}`); - return [match[1], match[2]]; - }), - ); + const values = {}; + for (const argument of argv.slice(1)) { + const match = /^--([a-z-]+)=(.+)$/.exec(argument); + if (!match || Object.hasOwn(values, match[1])) { + fail(`unsupported argument ${argument}`); + } + values[match[1]] = match[2]; + } + const supported = new Set([ + 'max-artifact-files', + 'max-artifact-bytes', + 'max-rss-delta-bytes', + 'output-directory', + ]); + for (const name of Object.keys(values)) { + if (!supported.has(name)) fail(`unsupported argument --${name}`); + } return Object.freeze({ profile, runtimeProfile: profile.startsWith('edge') ? 'edge' : 'standalone', @@ -128,6 +167,7 @@ function parseArguments(argv) { profile.includes('-application') && profile.endsWith('-ai') ? MIN_APPLICATION_AI_ARTIFACT_HEADROOM_BYTES : 0, + outputDirectory: outputDirectoryArgument(values['output-directory']), }); } @@ -221,8 +261,7 @@ function auditImportClosure( } const forbidden = result.loaded.filter( (filePath) => - (!allowSemver && - /node_modules[\\/]semver(?:[\\/]|$)/i.test(filePath)) || + (!allowSemver && /node_modules[\\/]semver(?:[\\/]|$)/i.test(filePath)) || /(?:node_modules[\\/](?:croner|pg|drizzle-orm|sequelize|sqlite3)(?:[\\/]|$)|node_modules[\\/]@qinglong[\\/]cluster-|local-sqlite[\\/]dist[\\/]migration\.js$|local-sqlite[\\/]dist[\\/]migrations[\\/])/i.test( filePath, ), @@ -309,8 +348,7 @@ function auditLocalApiExecutable(artifactDirectory) { artifactDirectory, ); if ( - output !== - 'Usage: ql3-local-api --config /absolute/private-config.json' + output !== 'Usage: ql3-local-api --config /absolute/private-config.json' ) { fail('local API executable help output is invalid'); } @@ -670,6 +708,13 @@ function main() { `import RSS delta is ${closure.rssDeltaBytes} bytes, budget is ${options.maxRssDeltaBytes}`, ); } + if (options.outputDirectory !== undefined) { + fs.cpSync(artifactDirectory, options.outputDirectory, { + recursive: true, + errorOnExist: true, + force: false, + }); + } process.stdout.write( `${JSON.stringify({ schemaVersion: 1, @@ -680,8 +725,7 @@ function main() { maxArtifactFiles: options.maxArtifactFiles, maxArtifactBytes: options.maxArtifactBytes, artifactHeadroomBytes, - minimumArtifactHeadroomBytes: - options.minimumArtifactHeadroomBytes, + minimumArtifactHeadroomBytes: options.minimumArtifactHeadroomBytes, prunedRuntimeDevelopmentFiles: prunedRuntimeArtifact.development.files, prunedRuntimeDevelopmentBytes: prunedRuntimeArtifact.development.bytes, runtimeJavaScriptFilesBefore: @@ -716,10 +760,8 @@ function main() { prunedRuntimeArtifact.packageManifests.runtimeExports .excludedSpecifiers, prunedRuntimeArtifactBytes: prunedRuntimeArtifact.savedBytes, - prunedMcpExternalDevelopmentFiles: - prunedMcpExternalDevelopment.files, - prunedMcpExternalDevelopmentBytes: - prunedMcpExternalDevelopment.bytes, + prunedMcpExternalDevelopmentFiles: prunedMcpExternalDevelopment.files, + prunedMcpExternalDevelopmentBytes: prunedMcpExternalDevelopment.bytes, loadedModuleCount: closure.loadedModuleCount, rssDeltaBytes: closure.rssDeltaBytes, maxRssDeltaBytes: options.maxRssDeltaBytes, diff --git a/test/back/ql3LocalApiCancellationLiveContract.test.cjs b/test/back/ql3LocalApiCancellationLiveContract.test.cjs new file mode 100644 index 00000000..6047c12e --- /dev/null +++ b/test/back/ql3LocalApiCancellationLiveContract.test.cjs @@ -0,0 +1,123 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const { + validateLocalApiCancellationLiveReport, +} = require('../../scripts/ql3-local-api-cancellation-live-audit.cjs'); + +function report(profile = 'edge') { + return { + schemaVersion: 1, + profile, + platform: { os: 'linux', architecture: 'arm64', procfs: true }, + resourceEnvelope: { + memoryBytes: profile === 'edge' ? 128 * 1024 * 1024 : 256 * 1024 * 1024, + pids: profile === 'edge' ? 64 : 256, + apiRssBytes: 80 * 1024 * 1024, + }, + observations: { + taskStartAccepted: true, + cancellationAccepted: true, + exactReplay: true, + durableIntentEvents: 1, + durableCancellationEvents: 1, + durableAllowedAudits: 2, + processIdentityObserved: true, + processIdentityGone: true, + restartObservedCancelled: true, + sqliteIntegrity: 'ok', + }, + qualification: { + evidenceClass: 'linux_virtualized_live_contract', + physicalDevice: false, + passed: true, + }, + compatible: true, + artifact: { + profile: `${profile}-application-api`, + bytes: 4 * 1024 * 1024, + files: 429, + loadedModules: 85, + compatible: true, + }, + }; +} + +test('accepts exact Edge and Standalone API to process-stop evidence', () => { + for (const profile of ['edge', 'standalone']) { + assert.deepEqual(validateLocalApiCancellationLiveReport(report(profile)), { + compatible: true, + findings: [], + }); + } +}); + +test('rejects missing process, replay, resource, artifact or qualification facts', () => { + for (const mutate of [ + (value) => { + value.observations.processIdentityGone = false; + }, + (value) => { + value.observations.exactReplay = false; + }, + (value) => { + value.resourceEnvelope.memoryBytes += 1; + }, + (value) => { + value.artifact.bytes = 6 * 1024 * 1024 + 1; + }, + (value) => { + value.qualification.physicalDevice = true; + }, + (value) => { + value.unreviewed = true; + }, + ]) { + const value = report(); + mutate(value); + assert.equal( + validateLocalApiCancellationLiveReport(value).compatible, + false, + ); + } +}); + +test('requires a fresh private report before Docker opt-in is checked', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-cancel-test-')); + try { + const script = path.resolve( + __dirname, + '../../scripts/ql3-local-api-cancellation-live-contract.cjs', + ); + const reportPath = path.join(root, 'report.json'); + const result = spawnSync( + process.execPath, + [script, '--profile=edge', `--report=${reportPath}`], + { + encoding: 'utf8', + env: { ...process.env, QL3_LOCAL_API_CANCELLATION_LIVE: '0' }, + }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /refusing to run Docker/); + assert.equal(fs.existsSync(reportPath), false); + fs.writeFileSync(reportPath, '{}', { mode: 0o600 }); + const existing = spawnSync( + process.execPath, + [script, '--profile=edge', `--report=${reportPath}`], + { + encoding: 'utf8', + env: { ...process.env, QL3_LOCAL_API_CANCELLATION_LIVE: '1' }, + }, + ); + assert.equal(existing.status, 1); + assert.match(existing.stderr, /fresh normalized absolute/); + assert.doesNotMatch(existing.stderr, /docker/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});