diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 2d9cfe03..0cb2d5ec 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-28): +- D-419/ADR-0514(实现中):二十天研发的阶段产物验收从“能下载、能启动、能打开 Console”推进到“能完成第一份真实自动化”。首 Owner bootstrap ready record 不再要求部署者手工拼接 token;短生命周期 operator 新增独立、no-replace、可幂等重放且不返回 Secret 的 `owner.credential-presentation.install-from-delivery`,在同 UID、`0700/0600`、deployment-root containment、no-symlink 和原子发布边界内生成标准 credential presentation,同时不修改 HIGH 风险的 delivery record/recovery/acknowledgement 协议。headless 与 Console 都获得后续管理所需的 presentation,但只有 opt-in Console quickstart 创建默认不自动运行、无网络/SecretRef/Trigger 的 `alpha-first-automation`;用户仍需核对 revision/content digest fence 后显式启动。原生 amd64/arm64 Console 门必须使用该真实 Owner credential 读取 Task、接受一次 fenced Run、等待 `succeeded` 并从 bounded log 看到固定工作标记。Trial Kit/verification 升为 `@v5/@v3`;默认低配 headless 不新增示例数据、listener、daemon、timer、依赖或稳态 RSS,Cluster 不复用本机 SQLite credential 路径。首份真实双架构 v5 大产物仍待维护者显式授权。 + - D-418/ADR-0513(已实现,首份真实双架构产物待授权):二十天研发形成的 headless 用户旅程与 opt-in Console 已闭合为部署者可选择的 Local Alpha 交付链。手动门新增默认 `headless` 的 `local_alpha_variant=headless|console`;一次授权 run 只生成所选变体,不把两套 Application 塞入同一 archive。默认 Docker/runtime 与路由/NAS 继续使用原 8 个内部 package、无 listener 的 headless 镜像;独立 `runtime-console` target 增加 Local API/Console,固定 12-package、6 MiB/640-file、AI-excluded 闭包。Console quickstart 仅支持 Linux Docker host,使用 host network 让容器内仍严格绑定 `127.0.0.1:5700` 的服务可由宿主访问,远程只能显式 SSH tunnel,不放宽为 `0.0.0.0` 或 public ingress。Trial Kit、verification、Local milestone 与 stage index 分别升级为 `qinglong/alpha-local-trial-kit@v4`、`qinglong/alpha-local-trial-kit-verification@v2`、`qinglong/alpha-local-milestone@v2`、`qinglong/alpha-stage-index@v2`,variant 贯穿 archive/SBOM/evidence/artifact name/deployment selection,拒绝跨变体与跨架构混用。普通双架构 CI 同时构建、Trivy 扫描、库存/SBOM 对账和实跑两种镜像;Console 必须完成 fresh Owner、loopback 首页 200、未认证 API 401 与 graceful stop。实际大 archive 仍只由维护者显式 `produce_alpha_artifacts=true` 生成;没有同 run/attempt 的双架构 milestone 时,单个 archive 仍只是工程中间物,不冒充 public release、生产 HA、签名或 LTS。 - D-417/ADR-0512(已实现,部署交付由 D-418 闭合):开发约二十天后的阶段产物从“可直接启动的 headless runtime”继续推进到首个可操作 Web 产品面。既有可选 `@qinglong/local-api` 现在内置 `/`、`/console.css`、`/console.js` 三文件离线 Console,总计 43,252 bytes,无前端框架、CDN、网络字体、遥测或 Web Storage;它复用同一 Node 进程、loopback listener、SQLite authority、Bearer/Project Policy/durable audit/re-confirm 链,不新增 package、sidecar、数据库连接、timer、watcher 或 cache。部署者可以按 Project 查看 Task revision/content fence、显式确认启动,查看 durable Run、Workflow Step 数与 Event sequence,并对未终止 Run 提交显式 cancellation request;取消请求不会伪装成停止终态。Credential 只在当前页面内存,服务端固定 CSP/COOP/CORP/Permissions Policy/no-referrer/nosniff/DENY/no-store,远程设备只能由操作者建立 SSH tunnel,不开放 LAN/public listener。默认 Edge/Standalone 与 D-416 Trial Kit 继续 headless、零增量;Console 只进入 opt-in `edge-application-api|standalone-application-api`,两档 artifact 为 `3,953,346 / 3,953,490 bytes`、467 files、12 packages、90 loaded modules,低于 6 MiB/640-file 门。Local API 48/48、pruner 11/11、完整 backend `1,637 total / 1,635 pass / 2 conditional skip / 0 fail`、18-package clean build/test 退出 0;package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency、122-module Edge import 与全部 14 档 Local artifact audit 均 compatible。桌面 1440×960 与手机 390×844 已检查 Task、Run/Event spine 和显式确认工作态。D-417 当时只形成 source/application-api 制品;其 Console image/quickstart 下载交付已由 D-418 完成,固定低性能物理 Edge 容量数据和 public release 仍是后续门。 diff --git a/docs/adr/ADR-0514-stage-usable-first-automation-journey.md b/docs/adr/ADR-0514-stage-usable-first-automation-journey.md new file mode 100644 index 00000000..163b3cba --- /dev/null +++ b/docs/adr/ADR-0514-stage-usable-first-automation-journey.md @@ -0,0 +1,66 @@ +# ADR-0514:阶段可用的首个自动化旅程 + +- 状态:Accepted(首份真实双架构 v5 Trial Kit 待维护者授权) +- 日期:2026-08-28 +- 对应 RFC 切片:D-419 + +## 背景 + +QingLong 3.0 已连续开发约二十天。D-416~D-418 已把 Local Alpha 从可构建镜像推进到可选择的 headless/Console Trial Kit:下载者可以离线校验、完成 fresh setup、建立首 Owner、启动受资源限制的 Application,并在 Console 中读取和启动既有 Task。主 CI 也已经在原生 amd64/arm64 上验证这些路径。 + +但 fresh 数据库没有 Task。更关键的是,Owner bootstrap ceremony 交付的 ready record 包含 claim 所需的完整记录,它不是 `ql3-task` 和 Web Console 接受的 credential presentation。让用户手工从 JSON 拼接 token,或让 Web Bearer 请求直接获得 strong 管理权限,都会把工程能力误报为阶段可用产品,并破坏既有认证边界。 + +## 决策 + +### 1. 增加独立、短生命周期的 credential presentation installer + +`ql3 owner` 新增 `owner.credential-presentation.install-from-delivery` command-file operation。它只把一个已交付的 Owner credential ready record 转换为标准 `qinglong3-local-identity-credential-presentation` 文件: + +- source、destination 必须位于同一个 canonical deployment root; +- root、父目录、source 和既有 destination 必须保持同 UID、`0700/0600`、非 symlink; +- destination 以临时文件、`fsync`、no-replace hard link 和目录 `fsync` 原子发布; +- 只允许首次 `installed`,或内容完全相同的 `existing` 重放;冲突时失败关闭; +- command 结果不返回 token、Secret、绝对路径或 delivery 内容。 + +该 installer 是新模块,不改变 `SecretDeliveryPrivateFilesystemStore` 的 record、recovery、acknowledgement 或 GC 语义。 + +### 2. quickstart 交付可直接使用的 Owner presentation + +headless 和 Console quickstart 都在首 Owner claim 后调用上述 operation,生成数据根内的 `owner-credential.json`。这使后续受支持的 `ql3 task|trigger|identity|policy|...` 短生命周期命令不再依赖部署者手工拼接 Secret。 + +bootstrap delivery 仍保留到操作者完成消费确认;installer 不把“复制成功”冒充 delivery acknowledgement。 + +### 3. Console Trial Kit 创建一个有界示例 Task + +只有显式选择 `console` variant 时,quickstart 才通过 strong local operator 创建 `alpha-first-automation`: + +- immutable `qinglong/command@v1`; +- 只执行 Application image 内的 `/bin/echo`; +- 无网络、无 SecretRef、无定时 Trigger,默认不自动运行; +- 用户仍需在 Console 核对 revision/content digest 并显式启动。 + +默认 headless 路由/NAS 不创建示例 Task,也不增加 listener、常驻进程、timer、连接池或稳态 RSS;它只获得后续管理需要的 credential presentation。Cluster/Kubernetes 不复用此 SQLite/本机 credential 路径。 + +### 4. 原生门必须证明真实工作完成 + +Console live journey 不再只检查首页 200 和未认证 API 401。每个原生架构还必须: + +1. 通过 operator 安装 Owner presentation; +2. 通过 operator 创建示例 Task; +3. 使用该 credential 从 loopback API 读取 Task; +4. 使用 revision/content digest fence 显式启动一次 Run; +5. 等待 `succeeded` 终态,并从 bounded Run log 看到固定工作标记; +6. 最后完成 graceful stop 和 SQLite integrity 检查。 + +Trial Kit 升级为 `qinglong/alpha-local-trial-kit@v5`(manifest schemaVersion 6),verification evidence 升级为 `qinglong/alpha-local-trial-kit-verification@v3`,新增 `ownerCredentialPresentation` 与 variant-aware `firstAutomationJourney` gate。Local milestone 与 stage index 文件形状不变,继续使用各自 v2 schema,但只能收录通过当前严格 auditor 的 bundle。 + +## 不采用的方案 + +- 不在 Web API 增加 Task create:HTTP Bearer principal 是 `single_factor`,不能伪装成 `local_console` strong assurance。 +- 不依赖宿主 `jq`、Node.js 或 shell 字符串拼接 Secret:Trial Kit 的宿主前置仍只有 POSIX shell、`sha256sum` 和 Docker。 +- 不修改 bootstrap ready record 为 presentation:claim、recovery 和 acknowledgement 已有独立安全语义,不能为 UI 便利改写。 +- 不把示例 Task 放进 headless:低配设备默认档保持最小、无操作面、无示例数据。 + +## 结果与剩余边界 + +D-419 形成“下载、验真、启动、认证、执行、观察终态”的首个阶段可用业务闭环。它仍是 fresh Alpha,不是 2.x 升级、生产 HA、公开 release、签名或 LTS。实际大 archive 继续只允许维护者显式设置 `produce_alpha_artifacts=true`;普通 push 的绿色 CI 证明源码可生成和实跑,不自动产生可下载大产物。 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index e305965f..85435947 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -23,6 +23,8 @@ | --- | --- | --- | | D-418 headless Trial Kit v4 | 默认低配变体;POSIX shell + Docker 一条命令完成 checksum、load、fresh setup、首 Owner、Application active/stop;无 listener | 首份远端双架构实际 artifact 仍需维护者授权 `produce_alpha_artifacts=true` | | D-418 Console Trial Kit v4 | 显式 Linux-only 变体;同一 quickstart 关闭 Owner ceremony 后启动 loopback Console,支持 Task/Run/Event、显式运行与取消;CI 验证首页 200、未认证 API 401 | 仍是 Alpha、无 public ingress/TLS/签名;首份实际 archive 同样需维护者授权 | +| D-419 Local Trial Kit v5 | headless/Console 都原子安装标准 Owner credential presentation;不再要求部署者手工拼接 bootstrap Secret | 首份实际双架构 v5 archive 仍需维护者授权 | +| D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 | D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。 @@ -52,7 +54,7 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif Local artifact 含: - 一个包含所选 Application 与短生命周期 operator 的 archive;headless 为 `qinglong3-local-trial-kit-.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-.docker.tar`,共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v4` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- schema 为 `qinglong/alpha-local-trial-kit@v5` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; - canonical `quickstart.sh`,在目标 Linux 设备上只依赖 POSIX shell、`sha256sum` 和 Docker,完成 checksum、load、identity、fresh Owner 与 Profile-bound Application active; - `verification-evidence.json` 绑定 `workflow_dispatch` 的 workflow ref/SHA、run ID/attempt、同架构两个 exact image ID 和完整 gate 集;下载者仍须到 GitHub 交叉检查 run,它不替代正式签名; - 与实际只读镜像 inventory 对账过的 CycloneDX SBOM; diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 1fae3300..20ae43af 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -25,7 +25,7 @@ sha256sum --check SHA256SUMS `manifest.json` 必须满足: -- `schema` 为 `qinglong/alpha-local-trial-kit@v4`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v5`; - `variant` 为 `headless` 或 `console`,并与 milestone、application SBOM 和 artifact 名一致; - `sourceRevision` 是你准备试用的完整 40 位 commit; - `architecture` 与主机相同; @@ -45,7 +45,7 @@ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ ## 一条命令完成 Fresh 试运行 -v4 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX +v5 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全 隔离的绝对路径: @@ -60,8 +60,9 @@ sh quickstart.sh standalone /srv/qinglong3-alpha-data ql3-alpha-standalone ``` 脚本会自动执行全包 checksum、加载 archive、核对 exact image ID/source/architecture、 -以当前 UID:GID 和无网络的短生命周期 operator 完成 fresh setup 与首 Owner 建立,随后按 -Profile 资源上限启动 Application。只有容器日志出现结构化 `active` 事件才返回成功。 +以当前 UID:GID 和无网络的短生命周期 operator 完成 fresh setup、首 Owner 建立与标准 +`owner-credential.json` presentation 安装,随后按 Profile 资源上限启动 Application。只有 +容器日志出现结构化 `active` 事件才返回成功。 Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command 结果保留在 `results/`;两者都位于 `0700` 私有根内,不会打印 Secret 到终端。 @@ -76,6 +77,11 @@ AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内 不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见 [Local Web Console](./ql3-local-web-console.md)。 +Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的 +`alpha-first-automation`。在页面输入 `owner-credential.json` 中的 token,选择该 Task, +核对 revision/content digest 后显式运行;它只执行 `/bin/echo` 固定标记,不使用网络、 +SecretRef 或 Trigger。headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。 + ## 手工加载与最小 smoke 从 `manifest.json.archive.file` 找到 archive 后加载: @@ -103,7 +109,7 @@ docker run --rm --read-only --network none --cap-drop ALL \ ## Fresh 试运行边界 -完整 fresh setup、首 Owner ceremony、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401。v4 artifact job 必须从将要上传的目录实际执行 `quickstart.sh` 并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 fresh setup、首 Owner ceremony、Owner presentation 安装、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401,并用真实 Owner credential 完成 Task read、fenced start、`succeeded` 终态与 bounded log marker。v5 artifact job 必须从将要上传的目录实际执行 `quickstart.sh` 并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 diff --git a/docs/operations/ql3-local-fresh-setup.md b/docs/operations/ql3-local-fresh-setup.md index dd2e4d58..8a1ab968 100644 --- a/docs/operations/ql3-local-fresh-setup.md +++ b/docs/operations/ql3-local-fresh-setup.md @@ -59,7 +59,33 @@ ql3-local-setup run --command-file /opt/qinglong3/setup.json 输出 key、digest 或路径。 setup 只准备存储和密钥 authority。随后使用 `ql3-owner` 完成 Identity provision、 -challenge、Owner claim 与 delivery acknowledgement。 +challenge 与 Owner claim。在提交 credential delivery acknowledgement 前,先把 ready record +安装为后续管理命令和 Console 可直接使用的标准 presentation: + +```json +{ + "schemaVersion": 1, + "operation": "owner.credential-presentation.install-from-delivery", + "options": { + "deploymentRoot": "/opt/qinglong3", + "databasePath": "/opt/qinglong3/qinglong3.sqlite", + "pepperPath": "/opt/qinglong3/owner-peppers/b3duZXItdjE.pepper", + "pepperKeyId": "owner-v1", + "secretDeliveryDirectory": "/opt/qinglong3/owner-delivery", + "profile": "edge", + "busyTimeoutMs": 100 + }, + "request": { + "credentialMutationId": "REPLACE_WITH_PROVISION_MUTATION_UUID", + "destinationFilePath": "/opt/qinglong3/owner-credential.json" + } +} +``` + +该 command file 与 destination 都必须是 `0600`;首次返回 `installed`,原样重放返回 +`existing`。结果不会返回 token 或路径。确认 consumer 已能用 +`owner-credential.json` 完成真实认证后,才执行 credential/challenge delivery +acknowledgement;不要先清理唯一 ready record。 ## 3. 创建 fresh application 配置 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index f62e4226..e5638376 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -1,6 +1,6 @@ # QingLong 3.0 Local Web Console -Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它不是当前 headless Local Alpha Trial Kit 的组成部分,也不是 2.x Web UI 的完整替代品。 +Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 ## 选择部署档位 @@ -11,7 +11,7 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查 | 普通单节点服务器 | 选择 `standalone-application-api` | | Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 | -当前仓库能生成 Node.js `application-api` 制品;D-416 的 v3 Docker Trial Kit 仍只包含 headless Application。需要下载即用的 Console image/quickstart 时,应等待后续 Console Trial Kit 切片,不能把本页命令误认为当前公开下载物。 +D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。 ## 前置条件 @@ -54,7 +54,7 @@ ssh -L 5701:127.0.0.1:5701 router.example ## 使用 1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。 -2. 在“任务”中选择 Task,核对 revision/content fence 后才能“运行一次”。 +2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。 3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度。 4. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 5. 完成后选择“断开并清除凭据”,再关闭页面。 diff --git a/packages/ql3-local-owner-cli/src/application-command/localOwnerCommand.ts b/packages/ql3-local-owner-cli/src/application-command/localOwnerCommand.ts index e944b104..c87d77d4 100644 --- a/packages/ql3-local-owner-cli/src/application-command/localOwnerCommand.ts +++ b/packages/ql3-local-owner-cli/src/application-command/localOwnerCommand.ts @@ -3,6 +3,7 @@ import { readPrivateLocalCommandFile, } from '@qinglong/local-command-file'; import { + installLocalOwnerCredentialPresentation, LocalOwnerSecretDeliveryError, openLocalOwnerConsole, type ClaimLocalOwnerFromDeliveriesRequest, @@ -71,6 +72,16 @@ export interface AcknowledgeLocalOwnerDeliveryCommand { }; } +export interface InstallLocalOwnerCredentialPresentationCommand { + readonly schemaVersion: 1; + readonly operation: 'owner.credential-presentation.install-from-delivery'; + readonly options: OpenLocalOwnerConsoleOptions; + readonly request: { + readonly credentialMutationId: string; + readonly destinationFilePath: string; + }; +} + export interface IssueLocalOwnerCredentialRecoveryCommand { readonly schemaVersion: 1; readonly operation: 'owner.credential-recovery.issue'; @@ -101,6 +112,7 @@ export type LocalOwnerCommand = | ClaimLocalOwnerCommand | InspectLocalOwnerDeliveryCommand | AcknowledgeLocalOwnerDeliveryCommand + | InstallLocalOwnerCredentialPresentationCommand | IssueLocalOwnerCredentialRecoveryCommand | CompleteLocalOwnerCredentialRecoveryCommand; @@ -153,6 +165,7 @@ function normalizeCommand(value: unknown): Readonly { 'owner.claim.from-deliveries', 'owner.delivery.inspect', 'owner.delivery.acknowledge', + 'owner.credential-presentation.install-from-delivery', 'owner.credential-recovery.issue', 'owner.credential-recovery.complete', ]; @@ -222,6 +235,20 @@ function validateAcknowledgementRequest( } } +function validatePresentationInstallRequest( + value: InstallLocalOwnerCredentialPresentationCommand['request'], +): void { + if ( + !exactKeys(value, ['credentialMutationId', 'destinationFilePath']) || + !UUID_V4_PATTERN.test(value.credentialMutationId) || + typeof value.destinationFilePath !== 'string' + ) { + throw new LocalOwnerCliConfigurationError( + 'credential presentation install request is invalid', + ); + } +} + function missing(error: unknown): boolean { let current = error; for (let depth = 0; depth < 4; depth += 1) { @@ -355,6 +382,22 @@ async function execute( ttlMs: acknowledgement.ttlMs, }); } + case 'owner.credential-presentation.install-from-delivery': { + validatePresentationInstallRequest(command.request); + const result = installLocalOwnerCredentialPresentation({ + deploymentRoot: command.options.deploymentRoot, + deliveryFilePath: console.credentialDeliveryPath( + command.request.credentialMutationId, + ), + destinationFilePath: command.request.destinationFilePath, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status: result.status, + credentialMutationId: result.credentialMutationId, + }); + } case 'owner.credential-recovery.issue': { const result = await console.credentialRecovery.issue(command.request); const delivery = deliveryAfterMutation(result.status, () => diff --git a/packages/ql3-local-owner-cli/test/ownerCli.test.cjs b/packages/ql3-local-owner-cli/test/ownerCli.test.cjs index 4fc60b49..3c3a3264 100644 --- a/packages/ql3-local-owner-cli/test/ownerCli.test.cjs +++ b/packages/ql3-local-owner-cli/test/ownerCli.test.cjs @@ -145,6 +145,64 @@ test('completes fresh Owner and credential recovery ceremonies without returning assert.equal(claimed.status, 'inserted'); assert.equal(claimed.role, 'owner'); assert.equal(JSON.stringify(claimed).includes('secret'), false); + const credentialFilePath = path.join( + state.options.deploymentRoot, + 'owner-credential.json', + ); + const installed = await runner.run( + commandFile( + state, + 'owner.credential-presentation.install-from-delivery', + { credentialMutationId, destinationFilePath: credentialFilePath }, + '04b-install-presentation', + ), + ); + assert.deepEqual(installed, { + schemaVersion: 1, + operation: 'owner.credential-presentation.install-from-delivery', + status: 'installed', + credentialMutationId, + }); + const presentation = JSON.parse(fs.readFileSync(credentialFilePath, 'utf8')); + assert.equal( + presentation.kind, + 'qinglong3-local-identity-credential-presentation', + ); + assert.match( + presentation.token, + /^ql3c_own_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/, + ); + assert.equal(fs.statSync(credentialFilePath).mode & 0o777, 0o600); + const replayedInstall = await runner.run( + commandFile( + state, + 'owner.credential-presentation.install-from-delivery', + { credentialMutationId, destinationFilePath: credentialFilePath }, + '04c-replay-presentation', + ), + ); + assert.equal(replayedInstall.status, 'existing'); + assertNoSecretFields(installed); + assertNoSecretFields(replayedInstall); + const escapedDestination = path.join( + os.tmpdir(), + `ql3-owner-credential-escape-${process.pid}.json`, + ); + await assert.rejects( + runner.run( + commandFile( + state, + 'owner.credential-presentation.install-from-delivery', + { + credentialMutationId, + destinationFilePath: escapedDestination, + }, + '04d-reject-escaped-presentation', + ), + ), + /destinationFilePath must be a descendant of deploymentRoot/, + ); + assert.equal(fs.existsSync(escapedDestination), false); for (const [purpose, mutationId, digest, suffix] of [ [ 'credential-provisioning', diff --git a/packages/ql3-local-owner-console/src/application-runtime/localOwnerConsole.ts b/packages/ql3-local-owner-console/src/application-runtime/localOwnerConsole.ts index 72a504f1..cba8c67f 100644 --- a/packages/ql3-local-owner-console/src/application-runtime/localOwnerConsole.ts +++ b/packages/ql3-local-owner-console/src/application-runtime/localOwnerConsole.ts @@ -46,6 +46,12 @@ export { type LocalOwnerSecretDeliverySummary, type LocalOwnerSecretRecoverySummary, } from '../delivery/secretDelivery'; +export { + LocalOwnerCredentialPresentationInstallError, + installLocalOwnerCredentialPresentation, + type InstallLocalOwnerCredentialPresentationOptions, + type LocalOwnerCredentialPresentationInstallSummary, +} from '../delivery/credentialPresentationInstaller'; export { LocalOwnerPepperConfigurationError, LocalOwnerPepperConflictError, diff --git a/packages/ql3-local-owner-console/src/delivery/credentialPresentationInstaller.ts b/packages/ql3-local-owner-console/src/delivery/credentialPresentationInstaller.ts new file mode 100644 index 00000000..a9b629e2 --- /dev/null +++ b/packages/ql3-local-owner-console/src/delivery/credentialPresentationInstaller.ts @@ -0,0 +1,353 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { normalizeLocalOwnerBootstrapSecretDeliveryRecord } from './secret-delivery/ceremonyContracts'; +import { formatApiCredentialToken } from '@qinglong/runtime-core/api-credential-token'; + +const MAX_PATH_BYTES = 4096; +const MAX_DELIVERY_BYTES = 4 * 1024; +const MAX_PRESENTATION_BYTES = 1024; + +export interface InstallLocalOwnerCredentialPresentationOptions { + readonly deploymentRoot: string; + readonly deliveryFilePath: string; + readonly destinationFilePath: string; +} + +export interface LocalOwnerCredentialPresentationInstallSummary { + readonly status: 'installed' | 'existing'; + readonly credentialMutationId: string; +} + +export class LocalOwnerCredentialPresentationInstallError extends Error { + readonly code = 'LOCAL_OWNER_CREDENTIAL_PRESENTATION_INSTALL_FAILED'; + + constructor(message: string, readonly cause?: unknown) { + super(`Local Owner credential presentation install failed: ${message}`); + this.name = 'LocalOwnerCredentialPresentationInstallError'; + } +} + +interface PrivateFile { + readonly device: bigint; + readonly inode: bigint; + readonly size: bigint; +} + +function boundedPath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + path.parse(value).root === value || + path.normalize(value) !== value || + value.includes('\0') || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + throw new LocalOwnerCredentialPresentationInstallError( + `${label} must be a bounded normalized absolute non-root path`, + ); + } + return value; +} + +function uid(): number { + if ( + typeof process.getuid !== 'function' || + typeof process.geteuid !== 'function' || + process.getuid() !== process.geteuid() + ) { + throw new LocalOwnerCredentialPresentationInstallError( + 'a stable POSIX user is required', + ); + } + return process.getuid(); +} + +function descendant(root: string, candidate: string, label: string): void { + const relative = path.relative(root, candidate); + if ( + relative.length === 0 || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new LocalOwnerCredentialPresentationInstallError( + `${label} must be a descendant of deploymentRoot`, + ); + } +} + +function privateDirectory( + directoryPath: string, + ownerUid: number, +): PrivateFile { + const stat = fs.lstatSync(directoryPath, { bigint: true }); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + Number(stat.uid) !== ownerUid || + (Number(stat.mode) & 0o777) !== 0o700 + ) { + throw new LocalOwnerCredentialPresentationInstallError( + 'authority directories must be private owned real directories', + ); + } + return Object.freeze({ device: stat.dev, inode: stat.ino, size: stat.size }); +} + +function privateFile( + filePath: string, + ownerUid: number, + maximumBytes: number, +): PrivateFile { + const stat = fs.lstatSync(filePath, { bigint: true }); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + Number(stat.uid) !== ownerUid || + (Number(stat.mode) & 0o777) !== 0o600 || + stat.size < 1n || + stat.size > BigInt(maximumBytes) + ) { + throw new LocalOwnerCredentialPresentationInstallError( + 'authority files must be bounded private owned regular files', + ); + } + return Object.freeze({ device: stat.dev, inode: stat.ino, size: stat.size }); +} + +function sameDirectory( + directoryPath: string, + ownerUid: number, + expected: PrivateFile, +): void { + const current = privateDirectory(directoryPath, ownerUid); + if (current.device !== expected.device || current.inode !== expected.inode) { + throw new LocalOwnerCredentialPresentationInstallError( + 'destination directory identity changed during installation', + ); + } +} + +function readPrivateJson( + filePath: string, + ownerUid: number, + maximumBytes: number, +): unknown { + const expected = privateFile(filePath, ownerUid, maximumBytes); + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + let material: Buffer | undefined; + try { + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + !opened.isFile() || + opened.dev !== expected.device || + opened.ino !== expected.inode || + opened.size !== expected.size + ) { + throw new LocalOwnerCredentialPresentationInstallError( + 'authority file identity changed while opening', + ); + } + material = fs.readFileSync(descriptor); + return JSON.parse(material.toString('utf8')) as unknown; + } catch (error) { + if (error instanceof LocalOwnerCredentialPresentationInstallError) { + throw error; + } + throw new LocalOwnerCredentialPresentationInstallError( + 'authority file is invalid', + error, + ); + } finally { + material?.fill(0); + fs.closeSync(descriptor); + } +} + +function presentation(value: unknown): Readonly<{ + schemaVersion: 1; + kind: 'qinglong3-local-identity-credential-presentation'; + token: string; +}> { + const record = normalizeLocalOwnerBootstrapSecretDeliveryRecord(value); + if (record.kind !== 'credential') { + throw new LocalOwnerCredentialPresentationInstallError( + 'delivery is not a credential record', + ); + } + return Object.freeze({ + schemaVersion: 1, + kind: 'qinglong3-local-identity-credential-presentation', + token: formatApiCredentialToken(record.credentialId, record.secret), + }); +} + +function existingPresentation( + filePath: string, + ownerUid: number, + expected: ReturnType, +): boolean { + const current = readPrivateJson(filePath, ownerUid, MAX_PRESENTATION_BYTES); + return JSON.stringify(current) === JSON.stringify(expected); +} + +function syncDirectory(directoryPath: string): void { + const descriptor = fs.openSync(directoryPath, 'r'); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +export function installLocalOwnerCredentialPresentation( + options: InstallLocalOwnerCredentialPresentationOptions, +): Readonly { + try { + const deploymentRoot = boundedPath( + options?.deploymentRoot, + 'deploymentRoot', + ); + const deliveryFilePath = boundedPath( + options?.deliveryFilePath, + 'deliveryFilePath', + ); + const destinationFilePath = boundedPath( + options?.destinationFilePath, + 'destinationFilePath', + ); + descendant(deploymentRoot, deliveryFilePath, 'deliveryFilePath'); + descendant(deploymentRoot, destinationFilePath, 'destinationFilePath'); + if (deliveryFilePath === destinationFilePath) { + throw new LocalOwnerCredentialPresentationInstallError( + 'delivery and destination files must be distinct', + ); + } + const ownerUid = uid(); + const rootIdentity = privateDirectory(deploymentRoot, ownerUid); + const destinationDirectory = path.dirname(destinationFilePath); + const destinationDirectoryIdentity = privateDirectory( + destinationDirectory, + ownerUid, + ); + const canonicalRoot = fs.realpathSync(deploymentRoot); + const canonicalDelivery = fs.realpathSync(deliveryFilePath); + const canonicalDestination = path.join( + fs.realpathSync(destinationDirectory), + path.basename(destinationFilePath), + ); + descendant(canonicalRoot, canonicalDelivery, 'deliveryFilePath'); + descendant(canonicalRoot, canonicalDestination, 'destinationFilePath'); + const delivery = normalizeLocalOwnerBootstrapSecretDeliveryRecord( + readPrivateJson(deliveryFilePath, ownerUid, MAX_DELIVERY_BYTES), + ); + if (delivery.kind !== 'credential') { + throw new LocalOwnerCredentialPresentationInstallError( + 'delivery is not a credential record', + ); + } + const expected = presentation(delivery); + if (fs.existsSync(destinationFilePath)) { + if (!existingPresentation(destinationFilePath, ownerUid, expected)) { + throw new LocalOwnerCredentialPresentationInstallError( + 'destination conflicts with the delivered credential', + ); + } + return Object.freeze({ + status: 'existing', + credentialMutationId: delivery.mutationId, + }); + } + const temporaryPath = path.join( + destinationDirectory, + `.owner-credential-presentation-${randomUUID()}.tmp`, + ); + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + temporaryPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + const serialized = `${JSON.stringify(expected)}\n`; + if (Buffer.byteLength(serialized, 'utf8') > MAX_PRESENTATION_BYTES) { + throw new LocalOwnerCredentialPresentationInstallError( + 'credential presentation exceeds its byte budget', + ); + } + fs.writeFileSync(descriptor, serialized, 'utf8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + sameDirectory( + destinationDirectory, + ownerUid, + destinationDirectoryIdentity, + ); + fs.linkSync(temporaryPath, destinationFilePath); + syncDirectory(destinationDirectory); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'EEXIST' && + existingPresentation(destinationFilePath, ownerUid, expected) + ) { + return Object.freeze({ + status: 'existing', + credentialMutationId: delivery.mutationId, + }); + } + if (error instanceof LocalOwnerCredentialPresentationInstallError) { + throw error; + } + throw new LocalOwnerCredentialPresentationInstallError( + 'credential presentation could not be published', + error, + ); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporaryPath); + syncDirectory(destinationDirectory); + } catch (error) { + if ( + !error || + typeof error !== 'object' || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } + } + } + sameDirectory(deploymentRoot, ownerUid, rootIdentity); + sameDirectory(destinationDirectory, ownerUid, destinationDirectoryIdentity); + if (!existingPresentation(destinationFilePath, ownerUid, expected)) { + throw new LocalOwnerCredentialPresentationInstallError( + 'published credential presentation is invalid', + ); + } + return Object.freeze({ + status: 'installed', + credentialMutationId: delivery.mutationId, + }); + } catch (error) { + if (error instanceof LocalOwnerCredentialPresentationInstallError) { + throw error; + } + throw new LocalOwnerCredentialPresentationInstallError( + 'credential presentation installation failed closed', + error, + ); + } +} diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 42fc9318..1a6772c8 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const SCHEMA = 'qinglong/alpha-local-trial-kit@v4'; -const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v2'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v5'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v3'; const QUICKSTART_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-quickstart.sh', @@ -40,6 +40,7 @@ const VERIFICATION = Object.freeze({ operator128MiBEntrypoint: 'passed', operatorPackageInventory: 'passed', freshOwnerJourney: 'passed', + ownerCredentialPresentation: 'passed', edgeFreshLifecycle: 'passed', standaloneFreshLifecycle: 'passed', localApiCancellation: 'passed', @@ -49,6 +50,7 @@ function verificationGates(variant) { return Object.freeze({ ...VERIFICATION, consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable', + firstAutomationJourney: variant === 'console' ? 'passed' : 'not_applicable', }); } const WORKFLOW_IDENTITY = Object.freeze({ @@ -302,7 +304,10 @@ function validateVerificationEvidence(document, expected) { document.workflow.job !== WORKFLOW_IDENTITY.job || !DECIMAL_ID_PATTERN.test(document.workflow.runId || '') || !ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') || - !exactKeys(document.gates, Object.keys(verificationGates(expected.variant))) || + !exactKeys( + document.gates, + Object.keys(verificationGates(expected.variant)), + ) || JSON.stringify(document.gates) !== JSON.stringify(verificationGates(expected.variant)) ) { @@ -618,7 +623,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { 0o700, ); const manifest = { - schemaVersion: 5, + schemaVersion: 6, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -721,7 +726,7 @@ function auditLocalAlphaTrialKit(options) { 'readme', 'verification', ]) || - manifest.schemaVersion !== 5 || + manifest.schemaVersion !== 6 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || diff --git a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs index 0dd8cce5..53443f71 100644 --- a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs +++ b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs @@ -90,8 +90,7 @@ function inspectImages(applicationImage, operatorImage, variant) { applicationLabels?.['io.qinglong.profile'] !== expectedProfile || applicationLabels?.['io.qinglong.ai'] !== 'excluded' || (variant === 'console' - ? applicationLabels?.['io.qinglong.local.console'] !== - 'offline-loopback' + ? applicationLabels?.['io.qinglong.local.console'] !== 'offline-loopback' : applicationLabels?.['io.qinglong.local.console'] !== undefined) || operatorLabels?.['io.qinglong.lifecycle'] !== 'short-lived' || operatorLabels?.['io.qinglong.authority'] !== 'local-owner-management' || @@ -285,6 +284,34 @@ function establishFirstOwner(state) { ) { fail('first Owner ceremony did not converge'); } + writePrivateJson(path.join(state.root, 'owner-credential-install.json'), { + schemaVersion: 1, + operation: 'owner.credential-presentation.install-from-delivery', + options: { + deploymentRoot: '/var/lib/qinglong3', + databasePath: '/var/lib/qinglong3/qinglong3.sqlite', + pepperPath: '/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper', + pepperKeyId: 'owner-v1', + secretDeliveryDirectory: '/var/lib/qinglong3/owner-delivery', + profile: state.profile, + busyTimeoutMs: 100, + }, + request: { + credentialMutationId, + destinationFilePath: '/var/lib/qinglong3/owner-credential.json', + }, + }); + const presentation = runOperator( + state, + 'owner', + 'owner-credential-install.json', + ); + if ( + presentation.status !== 'installed' || + presentation.credentialMutationId !== credentialMutationId + ) { + fail('Owner credential presentation did not install'); + } for (const acknowledgement of [ { file: 'owner-credential-ack.json', @@ -312,10 +339,68 @@ function establishFirstOwner(state) { provisioned: true, challenged: true, claimed: true, + credentialPresentationInstalled: true, acknowledged: true, }); } +function createFirstAutomationTask(state) { + if (state.variant !== 'console') { + return Object.freeze({ status: 'not_applicable' }); + } + writePrivateJson(path.join(state.root, 'alpha-first-task.json'), { + schemaVersion: 1, + operation: 'task.put', + options: { + deploymentRoot: '/var/lib/qinglong3', + databasePath: '/var/lib/qinglong3/qinglong3.sqlite', + profile: state.profile, + ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers', + credentialFilePath: '/var/lib/qinglong3/owner-credential.json', + busyTimeoutMs: 100, + }, + request: { + projectId: 'default', + taskId: 'alpha-first-automation', + expectedRevision: null, + mutationId: '019f8680-143d-4000-8000-000000000031', + requestId: 'alpha-trial-first-task', + failureAuditEventId: '019f8680-143d-4000-8000-000000000032', + name: 'QingLong 3.0 first automation', + description: + 'A bounded offline task proving the fresh Alpha installation can execute work', + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { + command: { + kind: 'argv', + file: '/bin/echo', + args: ['qinglong3-alpha-first-automation'], + }, + }, + }, + labels: { 'qinglong.alpha.example': 'true' }, + enabled: true, + occurredAtMs: 1_785_254_400_031, + }, + }); + const result = runOperator(state, 'task', 'alpha-first-task.json'); + if ( + result.status !== 'created' || + result.task?.taskId !== 'alpha-first-automation' || + result.task?.revision !== 1 || + result.task?.enabled !== true + ) { + fail('first automation Task did not converge'); + } + return Object.freeze({ + status: 'created', + taskId: result.task.taskId, + revision: result.task.revision, + }); +} + function writeApplicationConfig(state) { writePrivateJson(path.join(state.root, 'local-application.json'), { schema: 'qinglong/local-application-process@v2', @@ -346,8 +431,7 @@ function writeApplicationConfig(state) { writePrivateJson(path.join(state.root, 'local-api.json'), { schema: 'qinglong/local-api-process@v1', deploymentRoot: '/var/lib/qinglong3', - applicationConfigFilePath: - '/var/lib/qinglong3/local-application.json', + applicationConfigFilePath: '/var/lib/qinglong3/local-application.json', ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers', listener: { host: '127.0.0.1', port: 5700 }, }); @@ -358,7 +442,39 @@ function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -async function consoleSurfaceContract() { +function credentialToken(state) { + const value = JSON.parse( + fs.readFileSync(path.join(state.root, 'owner-credential.json'), 'utf8'), + ); + if ( + value?.schemaVersion !== 1 || + value?.kind !== 'qinglong3-local-identity-credential-presentation' || + typeof value?.token !== 'string' || + !/^ql3c_own_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/u.test(value.token) + ) { + fail('installed Owner credential presentation is invalid'); + } + return value.token; +} + +async function apiRequest(pathname, token, options = {}) { + const response = await fetch(`http://127.0.0.1:5700${pathname}`, { + redirect: 'manual', + signal: AbortSignal.timeout(2_000), + ...options, + headers: { + authorization: `Bearer ${token}`, + ...(options.body === undefined + ? {} + : { 'content-type': 'application/json' }), + ...(options.headers ?? {}), + }, + }); + const body = await response.json(); + return Object.freeze({ status: response.status, body }); +} + +async function consoleSurfaceContract(state) { let lastError; for (let attempt = 0; attempt < 20; attempt += 1) { try { @@ -380,10 +496,81 @@ async function consoleSurfaceContract() { `Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`, ); } + const token = credentialToken(state); + const task = await apiRequest( + '/api/v3/projects/default/tasks/alpha-first-automation', + token, + ); + if ( + task.status !== 200 || + task.body?.task?.revision !== 1 || + !/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '') + ) { + fail( + 'starter Task is not visible through the authenticated Console API', + ); + } + const started = await apiRequest( + '/api/v3/projects/default/tasks/alpha-first-automation/runs', + token, + { + method: 'POST', + body: JSON.stringify({ + schema: 'qinglong/task-start@v1', + mutationId: '019f8680-143d-7000-8000-000000000041', + expectedRevision: task.body.task.revision, + expectedContentDigest: task.body.task.contentDigest, + }), + }, + ); + if ( + started.status !== 202 || + started.body?.status !== 'accepted' || + typeof started.body?.runId !== 'string' || + typeof started.body?.attemptId !== 'string' + ) { + fail('starter Task did not accept one fenced Run'); + } + let terminal; + for (let attempt = 0; attempt < 80; attempt += 1) { + const current = await apiRequest( + `/api/v3/projects/default/runs/${started.body.runId}`, + token, + ); + if (current.status !== 200) fail('starter Run became unreadable'); + if ( + ['succeeded', 'failed', 'cancelled', 'timed_out'].includes( + current.body?.run?.status, + ) + ) { + terminal = current.body.run.status; + break; + } + await delay(250); + } + if (terminal !== 'succeeded') { + fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`); + } + const log = await apiRequest( + `/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`, + token, + ); + const logText = + log.status === 200 && log.body?.status === 'available' + ? Buffer.from(log.body.content, 'base64').toString('utf8') + : ''; + if (!logText.includes('qinglong3-alpha-first-automation')) { + fail('starter Run log does not contain the bounded work marker'); + } return Object.freeze({ listener: '127.0.0.1:5700', rootStatus: 200, unauthenticatedApiStatus: 401, + firstAutomation: Object.freeze({ + taskId: 'alpha-first-automation', + runStatus: terminal, + logMarkerObserved: true, + }), }); } catch (error) { lastError = error; @@ -458,7 +645,7 @@ async function runApplication(state) { surfacePromise = (async () => { try { if (state.variant === 'console') { - surface = await consoleSurfaceContract(); + surface = await consoleSurfaceContract(state); } } catch (error) { surfaceError = error; @@ -542,6 +729,7 @@ async function main() { try { const setup = prepareFreshAuthority(state); const owner = establishFirstOwner(state); + const firstAutomation = createFirstAutomationTask(state); writeApplicationConfig(state); const lifecycle = await runApplication(state); const database = new DatabaseSync(path.join(root, 'qinglong3.sqlite'), { @@ -565,8 +753,8 @@ async function main() { fail('durable SQLite result is invalid'); process.stdout.write( `${JSON.stringify({ - schemaVersion: 2, - schema: 'qinglong/local-alpha-trial-kit-live@v2', + schemaVersion: 3, + schema: 'qinglong/local-alpha-trial-kit-live@v3', variant: options.variant, profile: options.profile, architecture: images.architecture, @@ -576,6 +764,7 @@ async function main() { }, setup, owner, + firstAutomation, lifecycle, sqliteIntegrity: integrity, activeOwnerBindings: ownerCount, diff --git a/scripts/templates/ql3-local-alpha-quickstart.sh b/scripts/templates/ql3-local-alpha-quickstart.sh index 439c9bb9..839258ff 100644 --- a/scripts/templates/ql3-local-alpha-quickstart.sh +++ b/scripts/templates/ql3-local-alpha-quickstart.sh @@ -102,10 +102,16 @@ EOF cat >"$data_root/owner-claim.json" <"$data_root/owner-credential-install.json" <"$data_root/local-application.json" <"$data_root/alpha-first-task.json" <"$data_root/local-api.json" < { const paths = fixture(t); const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); - assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v4'); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v5'); assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); @@ -227,6 +227,8 @@ test('materializes a distinct loopback Console trial kit without widening the he ), ); assert.equal(verification.gates.consoleLiveJourney, 'passed'); + assert.equal(verification.gates.firstAutomationJourney, 'passed'); + assert.equal(verification.gates.ownerCredentialPresentation, 'passed'); const quickstartContents = fs.readFileSync( path.join(paths.outputRoot, 'quickstart.sh'), 'utf8', @@ -236,6 +238,7 @@ test('materializes a distinct loopback Console trial kit without widening the he assert.match(quickstartContents, /application_config=local-api\.json/); assert.match(quickstartContents, /"host":"127\.0\.0\.1","port":5700/); assert.match(quickstartContents, /Console: http:\/\/127\.0\.0\.1:5700\//); + assert.match(quickstartContents, /alpha-first-automation/); assert.match(quickstartContents, /do not expose the port on LAN/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); @@ -391,6 +394,7 @@ case " $* " in *'/owner-provision.json'*) printf '%s\\n' '{"status":"inserted"}'; exit 0 ;; *'/owner-challenge.json'*) printf '%s\\n' '{"status":"inserted"}'; exit 0 ;; *'/owner-claim.json'*) printf '%s\\n' '{"status":"inserted","role":"owner"}'; exit 0 ;; + *'/owner-credential-install.json'*) printf '%s\\n' '{"status":"installed"}'; exit 0 ;; esac exit 1 `, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index af2fe936..e02df730 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -172,10 +172,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerConsole.rootSourceFileRoles, }, { - sourceFiles: 19, + sourceFiles: 20, rootSourceFiles: 0, rootSourceLines: 0, - nestedSourceFiles: 19, + nestedSourceFiles: 20, rootSourceFileRoles: {}, }, );