feat(ql3): distribute copilot console via signed admin image

This commit is contained in:
whyour
2026-08-16 05:14:17 +08:00
parent c4a1238a92
commit fba8dfb602
22 changed files with 1176 additions and 29 deletions
+70 -6
View File
@@ -1,20 +1,50 @@
# Cluster Copilot read-only Console
This Console is an operator-workstation process, not a resident QingLong
service. It serves digest-bound assets on an ephemeral `127.0.0.1` port and
service. Native execution serves digest-bound assets on an ephemeral
`127.0.0.1` port and
forwards only `inspect` and explicit `output` reads to the existing Cluster
Copilot API. Do not deploy it as a Kubernetes workload, Ingress, shared LAN
listener, Edge component or legacy 2.x Web route.
Use `ql3-cluster-admin` from the same independently verified Admin release as
the Cluster deployment. The Console intentionally runs directly on the trusted
operator workstation. A container port mapping is not a supported substitute:
the process binds container loopback and must not be widened to `0.0.0.0`.
the Cluster deployment. D-328 also supports the image-carried
`docker-loopback.sh`: it uses an explicit container-only listener but publishes
the same port exclusively on host `127.0.0.1`. Arbitrary `0.0.0.0`, host
networking and LAN publication remain forbidden.
## Verify the distribution
The multi-architecture `qinglong3-cluster-admin@sha256:…` OCI image is the
distribution artifact. It already carries the exact launcher, examples and
this document under `/opt/qinglong/share/ql3-copilot-console/`; there is no
second Node archive or package dependency graph to trust.
From the exact reviewed source tag, run `verify-release.sh` with the immutable
image digest, repository, 40-hex source revision and full tag ref. The verifier
requires `cosign` and authenticated `gh`, then independently checks the keyless
release-workflow identity, SLSA provenance, CycloneDX SBOM and digest-bound OS
vulnerability evidence. It rejects tags and mutable image references.
```sh
deploy/console/ql3-cluster-copilot/verify-release.sh \
ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \
replace-owner/qinglong \
REPLACE_40_HEX_SOURCE_REVISION \
refs/tags/v3.0.0-alpha.0
```
After verification, pull that exact digest. The signature covers the embedded
host launcher and templates as part of the image filesystem. Operators may
either use the launcher from the matching reviewed tag or extract its exact
image-carried copy with `docker create` plus `docker cp` before execution.
## Prepare private authority
Create an absolute canonical directory owned by the current operator with mode
`0700`. Copy `client-config.example.json` to `client.json`, install the reviewed
Create an absolute canonical directory with mode `0700`. For native execution
it is owned by the current operator; for the image-carried launcher it and all
files are owned by UID/GID `10001:10001`. Copy `client-config.example.json` to
`client.json`, install the reviewed
Cluster API CA as `ca.pem`, and install a separately issued `ql3c_` Project API
credential as `credential`. Give the credential only `run.read` and
`artifact.read`; the Console has no route for diagnosis creation or
@@ -72,3 +102,37 @@ approximately 2 MiB, disables cache/cookies/frames/workers, and never polls.
Model text is rendered as plain text and remains untrusted advice. These limits
keep the workstation surface bounded, but this Cluster-only product is still
excluded from small router Edge/Standalone artifacts.
## Run the verified image
Create a dedicated Docker network whose egress is restricted by the host
firewall to DNS and the exact Cluster API destination. Copy
`host-environment.example.json` values into the launcher environment, replacing
the image with the verified digest and selecting one unused host port. The
launcher rejects `bridge|default|host|none`, mutable tags, noncanonical private
roots, ports outside `1024..65535` and unknown resource classes.
| Resource class | Memory | CPU | PIDs | Console reads |
| --- | ---: | ---: | ---: | ---: |
| `compact` | 192 MiB | 0.25 | 32 | 2, no queue |
| `standard` | 512 MiB | 1 | 64 | 2, no queue |
Validate private authority and the upstream unauthenticated TLS 1.3 readiness
route without opening or publishing a listener:
```sh
deploy/console/ql3-cluster-copilot/docker-loopback.sh check
```
Then start the foreground session:
```sh
deploy/console/ql3-cluster-copilot/docker-loopback.sh serve
```
The launcher fixes non-root UID, read-only root, no capabilities,
no-new-privileges, bounded memory/CPU/PIDs, an 8 MiB noexec tmpfs, one read-only
private mount and `--pull never`. `serve` alone adds
`--publish 127.0.0.1:<port>:<port>/tcp`; `check` publishes nothing. The
container listener is reachable only through this reviewed publication and
continues to require the 256-bit browser session token plus exact Host/Origin.
+88
View File
@@ -0,0 +1,88 @@
#!/bin/sh
set -eu
usage() {
printf '%s\n' 'Usage: docker-loopback.sh check|serve' >&2
exit 64
}
fail() {
printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-copilot-console-launcher","event":"launch_failed"}' >&2
exit 78
}
[ "$#" -eq 1 ] || usage
mode=$1
case "$mode" in
check|serve) ;;
*) usage ;;
esac
image=${QL3_COPILOT_CONSOLE_IMAGE-}
private_root=${QL3_COPILOT_CONSOLE_PRIVATE_ROOT-}
network=${QL3_COPILOT_CONSOLE_NETWORK-}
port=${QL3_COPILOT_CONSOLE_PORT-}
resource_class=${QL3_COPILOT_CONSOLE_RESOURCE_CLASS-compact}
printf '%s' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}@sha256:[0-9a-f]{64}$' || fail
printf '%s' "$network" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$' || fail
case "$network" in
bridge|default|host|none) fail ;;
esac
printf '%s' "$port" | grep -Eq '^[1-9][0-9]{3,4}$' || fail
[ "$port" -ge 1024 ] 2>/dev/null || fail
[ "$port" -le 65535 ] 2>/dev/null || fail
case "$private_root" in
/*) ;;
*) fail ;;
esac
case "$private_root" in
*','*|*':'*) fail ;;
esac
[ -d "$private_root" ] || fail
canonical_root=$(CDPATH= cd -- "$private_root" 2>/dev/null && pwd -P) || fail
[ "$canonical_root" = "$private_root" ] || fail
case "$resource_class" in
compact)
memory=192m
cpus=0.25
pids=32
;;
standard)
memory=512m
cpus=1
pids=64
;;
*) fail ;;
esac
set -- docker run --rm --pull never --init --read-only \
--network "$network" \
--cap-drop ALL \
--security-opt no-new-privileges \
--user 10001:10001 \
--pids-limit "$pids" \
--memory "$memory" \
--cpus "$cpus" \
--stop-timeout 3 \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001 \
--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-console,readonly"
if [ "$mode" = serve ]; then
set -- "$@" --publish "127.0.0.1:$port:$port/tcp"
fi
set -- "$@" "$image" copilot-console \
--container-published-loopback \
"--port=$port" \
--config /var/run/secrets/qinglong3/copilot-console/client.json \
--credential /var/run/secrets/qinglong3/copilot-console/credential \
--session /var/run/secrets/qinglong3/copilot-console/session
if [ "$mode" = check ]; then
set -- "$@" --check
fi
exec "$@"
@@ -0,0 +1,7 @@
{
"QL3_COPILOT_CONSOLE_IMAGE": "ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"QL3_COPILOT_CONSOLE_PRIVATE_ROOT": "/absolute/private/ql3-copilot-console",
"QL3_COPILOT_CONSOLE_NETWORK": "qinglong3-copilot-console-egress",
"QL3_COPILOT_CONSOLE_PORT": "5701",
"QL3_COPILOT_CONSOLE_RESOURCE_CLASS": "compact"
}
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
usage() {
printf '%s\n' 'Usage: verify-release.sh IMAGE@sha256:DIGEST OWNER/REPO SOURCE_REVISION refs/tags/v3.VERSION' >&2
exit 64
}
fail() {
printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","event":"verification_failed"}' >&2
exit 78
}
[ "$#" -eq 4 ] || usage
image=$1
repository=$2
source_revision=$3
source_ref=$4
printf '%s' "$repository" | grep -Eq '^[a-z0-9][a-z0-9-]{0,38}/[A-Za-z0-9_.-]{1,100}$' || fail
owner=${repository%%/*}
printf '%s' "$image" | grep -Eq "^ghcr.io/$owner/qinglong3-cluster-admin@sha256:[0-9a-f]{64}$" || fail
printf '%s' "$source_revision" | grep -Eq '^[0-9a-f]{40}$' || fail
printf '%s' "$source_ref" | grep -Eq '^refs/tags/v3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' || fail
command -v cosign >/dev/null 2>&1 || fail
command -v gh >/dev/null 2>&1 || fail
workflow="$repository/.github/workflows/ql3-image-release.yml"
certificate_identity="https://github.com/$workflow@$source_ref"
cosign verify \
--certificate-identity "$certificate_identity" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$image" >/dev/null || fail
verify_attestation() {
predicate_type=$1
if [ -n "$predicate_type" ]; then
set -- --predicate-type "$predicate_type"
else
set --
fi
gh attestation verify "oci://$image" \
--repo "$repository" \
--signer-workflow "$workflow" \
--source-digest "$source_revision" \
--source-ref "$source_ref" \
"$@" \
--deny-self-hosted-runners \
--bundle-from-oci >/dev/null || fail
}
verify_attestation ''
verify_attestation https://cyclonedx.org/bom
verify_attestation https://qinglong.dev/attestations/image-os-vulnerability/v1
printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","signature":true,"provenance":true,"sbom":true,"osVulnerabilityEvidence":true,"compatible":true}'
@@ -76,6 +76,17 @@ COPY --from=workspace /workspace/packages/ql3-cluster-admin/dist \
node_modules/@qinglong/cluster-admin/dist
COPY --from=workspace /workspace/packages/ql3-cluster-admin/assets/copilot-console \
node_modules/@qinglong/cluster-admin/assets/copilot-console
RUN mkdir -p share/ql3-copilot-console
COPY --chmod=0555 deploy/console/ql3-cluster-copilot/docker-loopback.sh \
share/ql3-copilot-console/docker-loopback.sh
COPY --chmod=0555 deploy/console/ql3-cluster-copilot/verify-release.sh \
share/ql3-copilot-console/verify-release.sh
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/README.md \
share/ql3-copilot-console/README.md
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/client-config.example.json \
share/ql3-copilot-console/client-config.example.json
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json \
share/ql3-copilot-console/host-environment.example.json
FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS runtime
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(2026-08-16):
- D-328/ADR-0420(已接受):Cluster Copilot Console 的 operator-workstation 分发已冻结为既有 `qinglong3-cluster-admin@sha256:…` 多架构 OCI,而不是再发布 Node archive、安装器镜像或第 19 个 workspace package。Admin release workflow 已有 amd64/arm64 原生构建、production dependency audit、BuildKit provenance/SBOM、OS vulnerability scan、扫描后 OCI merge、keyless Cosign signature,以及绑定 repository/workflow/source commit/source tag 的 GitHub provenance、CycloneDX 和 OS-vulnerability attestations;新增 `verify-release.sh` 要求 image digest、40-hex revision 与 `refs/tags/v3.*`,独立复验 exact workflow certificate identity、GitHub OIDC issuer、三类 predicate、OCI bundle 并拒绝 mutable tag、branch ref 与 self-hosted builder。已签名 image filesystem 现在携带 `0555` launcher/verifier 和 `0444` 文档/配置模板,因此 signature 同时覆盖宿主启动路径,不存在第二套依赖树。原生 Console 默认继续只监听 `127.0.0.1` ephemeral port;只有显式 `--container-published-loopback` 加固定高端口才允许容器 network namespace 内监听 `0.0.0.0`,并仍对外报告 `publishedHostAddress=127.0.0.1`。reviewed launcher 只接受 immutable digest、canonical private root、显式命名网络和 `compact|standard` 两档资源,拒绝 `bridge|default|host|none``check` 不 publish`serve` 唯一映射为 `127.0.0.1:<port>:<port>/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stopcompact 为 192 MiB/0.25 CPU/32 PIDsstandard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30release/OCI/SBOM 定向门 75/75package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0backend 1,223 pass/2 条件 skip/0 fail。npm pack dry-run 保持 245 files、258,591-byte tarball、1,616,582-byte unpacked。真实 arm64 image `qinglong3-cluster-admin:d328-local` 为 344,492,529 bytes,验证 10 个产品命令、镜像内五项分发文件及 mode、原生 loopback Console;新增 named-network 现场门选择一个空闲高端口并以 exact `127.0.0.1:P:P` 发布,宿主真实读取页面,同时复验 read-only/non-root/no-capability/no-new-privileges 边界与容器/网络清理。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 仍为 4,493,043/4,493,175 bytesMCP 仍为 7,315,930/7,316,038 bytes,证明工作站分发没有进入低配路由设备闭包。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`Cluster Admin 120 个源码中 119 个位于嵌套职责目录。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应在同一 Console/BFF ownership 下增加只读 Run/Task/Workflow 观察面,或补公开 release tag 的外部工作站 ceremony;不得把容器内部 listener 误当宿主 LAN authority、引入第二分发依赖树或把 Console 变成 Kubernetes 常驻服务。
- D-327/ADR-0419(已接受):QingLong 3.0 首个 Cluster 浏览器产品面已冻结为独立的 operator-workstation、loopback-only、只读 Copilot Console,而不是继续扩展 2.x Umi `src/pages`、legacy session 与 `/api` proxy。实现内聚在既有 `@qinglong/cluster-admin/copilot-console`workspace 仍为 18 个 package;统一产品 façade 增加第十个静态命令 `copilot-console`。BFF 只监听 `127.0.0.1` ephemeral port,启动前复验包内 HTML/CSS/JS 的路径、realpath、类型、UTF-8、大小与固定 SHA-256;三项资源合计 24,150 bytes,无外部 asset/font/CDN。Cluster `ql3c_` credential 始终留在服务端 owner-private `0600` 文件且每次上游调用重新读取;浏览器只使用另一份 exact 256-bit session key,服务端只保存 domain-separated digest,页面只保存在内存,不进入 cookie、URL、argv、environment、local/session storage。Browser BFF 仅接受 exact `inspect|output`,复用 D-324 共享 TypeScript client,不执行 CLI 子进程、不直连数据库/application capability,并明确没有 diagnose/cancel、poller、WebSocket/SSE、ServiceWorker、queue/retry/cache 或后台 timer。Host、Origin、单 Authorization、route/operation 和 JSON framing 必须 exact;第三个并发 read 立即 `429`,固定 4 KiB request、约 2 MiB response、2 in-flight、16 connections 和 2 秒 shutdown ceiling。响应全为 `no-store` 且使用 default-deny CSP;模型文本只通过 `textContent` 显示并持续标记为 untrusted/no-action-authority。部署手册固定受信运维工作站生命周期,禁止 Kubernetes workload、Ingress、sidecar、共享 LAN 和容器 `0.0.0.0`Edge/Standalone、Local MCP、Cluster Control/AI closure 均不导入 Console。npm pack dry-run 确认 245 files、258,012-byte tarball、1,614,503-byte unpacked,包含三项静态资源与全部 BFF/CLI 编译产物;独立审计还发现并修正真实 Admin Dockerfile 原先遗漏 assets 的发布缺陷,并把生产 files 白名单精确收窄到 `assets/copilot-console/*`。真实 Playwright 现场门覆盖 session 解锁、status read、显式 output reveal、390px 响应式布局和键盘路径;含 `<script>` 的模型输出保持纯文本,最终 0 error/0 warning,并修正了现代 HTML `/v` pattern 对未转义 `-` 的兼容问题。Console contract/CLI 12/12、定向产品入口 25/25、Cluster Admin 374 pass/3 条件 skip、完整 18-package clean build/test 退出 0、backend 1,215 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment/Console 审计零 findingOCI/release 64/64、SBOM 11/11。真实 arm64 Admin image `qinglong3-cluster-admin:d327-local` 为 344,479,739 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 10 个产品命令,并在同一受限容器内真实启动 Console、读取 digest-bound 页面与干净关闭。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes,证明 Cluster UI 没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应交付可独立验签的 operator-workstation Admin/Console 分发物,或在同一 3.0 Console ownership 下增加受同一 BFF 约束的只读 Run/Task/Workflow 观察面;不得回接 2.x session、把浏览器变成 Cluster credential holder,或把 Console 变为常驻 Kubernetes 服务。
- D-326/ADR-0418(已接受):Cluster Copilot MCP 已补齐明确的外部 host 部署与资源边界。MCP 仍是 stdio 子进程,必须由支持 MCP 的外部 host 按 session 启动并持有 stdin/stdout;它不部署为 Kubernetes Deployment/Service,否则会形成没有消费者却长期持有 Project credential 的孤儿进程。新增 `deploy/mcp/ql3-cluster-copilot/` 提供 digest-pinned host 配置、owner-private client/MCP 配置示例和固定 Docker launcherlauncher 只允许显式命名网络与 `compact|standard|dense` 三档资源,分别限制为 192 MiB/0.25 CPU/32 PIDs/并发 1、512 MiB/1 CPU/64 PIDs/并发 4、1 GiB/2 CPU/96 PIDs/并发 16,并强制 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有 authority root,禁止 Docker socket、Kubernetes token、数据库 credential、host/default/bridge/none 网络和可写工作目录。统一产品入口新增第九个静态命令 `ql3-cluster-admin copilot-mcp``ql3-copilot-mcp --check` 会先复验私有 config/credential/CA,再用无认证、固定 `GET /readyz` 做低敏预检,并在启动前拒绝配置并发超过 host resource class ceilingserve 路径仍保持无 listener、无 queue/retry/poller/cache。部署审计同时禁止任何 Kubernetes YAML 常驻该 MCP,并修正了一个真实发布缺陷:OCI layout 旧 fixture 仍声称 Admin 镜像入口是 recovery CLI,现已与真实 `product-cli/cli.js` entrypoint 对齐。workspace 仍为 18 package、无 single-source/shallow packageCluster Admin 保持 116 个源码、115 个位于嵌套职责目录,Admin SBOM 保持 91 components/87 external/4 internalControl 和全部 Local 闭包不变。专项发布审计 145/145、Cluster Admin 362 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,210 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment 审计零 finding。真实 arm64 Admin image `qinglong3-cluster-admin:d326-local` 为 344,423,357 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 9 个产品命令与新 entrypoint。14 档 Local artifact 全部逐档复验且与 D-325 完全一致:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes,证明 Cluster MCP host 部署没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership/read-only Copilot surface,或使用公开 release digest 补真实外部 host session 证据;均不得把 stdio MCP 改成常驻服务或扩大其 credential/网络 authority。
- D-325/ADR-0417(已接受):Cluster Copilot 现已提供独立、受限、可部署的 MCP stdio 产品面。实现没有扩展旧 2.x Web UI,也没有把 Cluster authority 塞入 Edge/Standalone 的 `@qinglong/local-mcp-server`;而是在既有 `@qinglong/cluster-admin` 的内聚 `copilot-mcp/` 目录新增 `ql3-copilot-mcp``./copilot-mcp` exportworkspace 仍保持 18 package。四个静态 Tool 只接收 Project、source Run、diagnosis request、trace/mutation identity,并直接调用 D-324 的共享 TypeScript client;不启动 CLI 子进程、不写 command 临时文件、不监听网络、不直连数据库/application capability,也不允许调用者提供 URL、header、credential、Model/Provider、Artifact、usage/cost 或 Policy fence。owner-private 0600 配置只保存 client config/credential 路径和显式 `1..16` 并发上限;credential 每次 Tool call 都重新执行 canonical/private/TOCTOU 与 token 校验,rotation 下一次调用立即生效。满载即时返回 `copilot_mcp_busy`,没有隐藏 queue、retry、poller、timer、watcher 或 cache。所有结果使用 exact `qinglong/cluster-copilot-mcp-result@v1`,固定 `instructionPolicy=data_only_never_execute``actionAuthority=none`;只有 output Tool 标为 `potentially_sensitive`/`untrusted_model_output`,远端错误仅投影有界 status/code/request identity/Retry-After。真实 stdio + TLS 1.3 E2E 已覆盖 initialize、discovery、四次直接请求、Bearer credential 热轮换、无 client certificate、敏感输出标注与 graceful close;并发和未知字段均失败关闭。Cluster Admin 完整测试 361 pass/3 条件 skip18-package clean build/test 退出 0backend 1,207 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment 四项审计零 findingCluster Admin 为 116 个源码且 115 个位于嵌套职责目录。Cluster Admin 镜像精确加入已固定的 `@modelcontextprotocol/server@2.0.0`SBOM 为 91 components/87 external/4 internalCluster Control 和全部 Local 闭包不变。14 档 Local artifact 全部通过,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes、315 files、56 modules,证明 Cluster MCP 没有进入低配路由设备;本 Gate 无 schema、migration、SQL、role、Pool、连接或部署拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership 或增加 MCP host 的明确部署清单/运维面,仍必须复用同一 API,不得回接 2.x controller/session 或扩大 credential authority。
@@ -0,0 +1,41 @@
# ADR-0420:以签名 Admin OCI 分发 Cluster Copilot 工作站 Console
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-328、Phase 2
## 背景
D-327 已把首个 QingLong 3.0 浏览器产品面冻结为 `@qinglong/cluster-admin` 内聚的短生命周期、只读 Console,但只证明了本机 loopback 进程与 Admin image 内运行。运维者仍需要一条可以独立验证、在 amd64/arm64 工作站一致部署、不会另造依赖树的分发路径。
当前 release workflow 已对 Admin OCI 的两个原生架构执行 production dependency audit、SBOM 生成、OS vulnerability scan、OCI 合并复验、keyless Cosign 签名和三类 GitHub attestation,并在提升 immutable tag 前从 registry 独立复验。再发布 Node tarball、安装器镜像或第 19 个 workspace package,会产生第二份版本/签名/依赖闭包,也会重新引入用户已指出的薄包问题。
容器内部不能监听宿主 `127.0.0.1` 后再由 Docker publish;若要提供宿主 loopback 入口,容器内必须监听其 network namespace 的 all-interface 地址。因此需要把“容器内部 listener”与“宿主可达边界”拆成两个显式、可审计的概念,不能把原生默认 listener 静默改宽。
## 决策
1. 唯一工作站分发物是 `qinglong3-cluster-admin@sha256:…` 多架构 OCI;不新增 archive、安装器 image、workspace package 或生产依赖。镜像签名覆盖 Console 代码、digest-bound assets、启动器、验签器、配置模板与部署文档。
2. 镜像内 `/opt/qinglong/share/ql3-copilot-console/` 固定携带只读文档/模板和 `0555``docker-loopback.sh``verify-release.sh`。运维者从 exact reviewed tag 使用脚本,或从已验证 digest 的 image filesystem 提取同一副本。
3. `verify-release.sh` 只接受与 repository owner 一致的 Admin image digest、40-hex source revision 和 `refs/tags/v3.*`。它必须验证 exact release workflow certificate identity、GitHub OIDC issuer,以及绑定 repository、workflow、commit、tag 的 provenance、CycloneDX SBOM 和 OS vulnerability attestation;拒绝 tag image、branch ref、self-hosted builder 和非 OCI bundle。
4. 原生 CLI 默认仍为 `host-loopback`,只监听 `127.0.0.1` 且允许 ephemeral port。只有显式 `--container-published-loopback` 加固定 `1024..65535` port 才让容器内监听 `0.0.0.0`start/preflight fact 始终声明 `publishedHostAddress=127.0.0.1`,该 mode 不能被普通原生启动隐式选择。
5. image launcher 只使用 immutable digest 和显式命名网络,拒绝 `bridge|default|host|none``serve` 只能添加 `--publish 127.0.0.1:<port>:<port>/tcp``check` 不开放 listener 或 publish。任意 LAN/all-interface 宿主发布不在受支持面内。
6. launcher 固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 private authority mount、`--pull never` 和 3 秒 stop ceiling。`compact` 为 192 MiB/0.25 CPU/32 PIDs`standard` 为 512 MiB/1 CPU/64 PIDs;两档均继承 Console 2 reads/no queue 的应用边界。
7. 宿主必须给 Console 建独立命名网络,并在宿主 firewall 将 egress 收窄到 DNS 和 exact Cluster API。launcher 不挂载 Docker socket、Kubernetes token、数据库 credential 或可写工作目录,也不声称 Docker bridge 本身提供 egress allowlist。
8. Console 继续排除在 Kubernetes YAML、Edge/Standalone、Local MCP、Cluster Control/AI closure 和 2.x Web 中;因此低配路由设备默认制品不承受新增字节、module、进程或常驻资源。
## 不选择
- **独立 Node tarball/桌面安装器**:会复制依赖闭包、签名与升级通道,当前没有独立 consumer 或 package 边界价值。
- **新增 Console workspace package**:实现仍由同一个 Cluster Admin consumer、release image 和权限域拥有,拆包只会得到浅目录或单职责文件包。
- **容器使用 host network**:绕过明确的 port binding,并扩大到宿主全部网络面。
- **直接发布容器 `0.0.0.0` 到宿主**:使局域网可达性依赖 daemon 默认,违反短生命周期受信工作站边界。
- **常驻 Kubernetes Console**:会把 Project credential、listener 和资源成本变成长生命周期集群工作负载。
## 验收
1. launcher 单测精确比较 compact check 与 standard serve 的 Docker argv,并证明 tag、ambient network、低端口、非 canonical/注入式 private root 和未知资源档在调用 Docker 前低敏失败。
2. verifier 单测用独立 fake `cosign`/`gh` 证明一次 signature 与三次 attestation 调用的 exact identity/source/predicate 约束,并证明 mutable/unbound 输入不会触发 trust tool。
3. 分发审计锁定镜像内文件 mode/path、双架构 release workflow、signature/attestation、显式 network boundary、host loopback publication 和 Kubernetes/Edge 缺席。
4. 真实 Admin image 必须在受限容器内证明嵌入文件与 mode,并先选择一个空闲高端口、再通过 named network + Docker `127.0.0.1:<port>:<port>` 现场证明唯一 loopback publication、exact Host/Origin 页面可读、read-only/non-root/no-capability 边界与干净回收。
5. Cluster Admin、18-package clean build/test、backend、release/SBOM/package/dependency/Edge 审计与 14 档 Local artifact 全部通过后,本 ADR 才转为 Accepted 并进行 D-328 阶段提交。
6. 本 Gate 不修改 schema、migration、SQL、role、Pool、连接或 PostgreSQL HA 拓扑;继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 基线,不以重复数据库门代替本阶段的分发验证。
+1
View File
@@ -423,6 +423,7 @@
| [ADR-0417](./ADR-0417-bounded-cluster-copilot-mcp-stdio-surface.md) | 有界 Cluster Copilot MCP stdio 产品面 | Accepted |
| [ADR-0418](./ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md) | 显式 Cluster Copilot MCP Host 部署与资源边界 | Accepted |
| [ADR-0419](./ADR-0419-loopback-read-only-cluster-copilot-console.md) | Loopback-only Cluster Copilot 只读 Console | Accepted |
| [ADR-0420](./ADR-0420-signed-admin-oci-console-workstation-distribution.md) | 以签名 Admin OCI 分发 Cluster Copilot 工作站 Console | Accepted |
## 规则
+1 -1
View File
@@ -60,7 +60,7 @@
"loopback-only read-only Copilot Console",
"one-shot maintenance"
],
"rationale": "独立管理进程、一次性高权限命令和 operator-workstation Console 必须排除在常驻 cluster-control 之外;Approval 的 service/transport/process/client 与只读 Copilot Console 均属于同一 Cluster Admin 制品,分别使用内聚领域目录而不是新增微包。"
"rationale": "独立管理进程、一次性高权限命令和 operator-workstation Console 必须排除在常驻 cluster-control 之外;Approval 的 service/transport/process/client 与只读 Copilot Console 均属于同一 Cluster Admin 制品,分别使用内聚领域目录而不是新增微包。Console 的宿主启动器、配置模板与发布验签脚本随已签名的多架构 Admin OCI 分发,不形成第 19 个 workspace package 或第二套依赖树。"
},
{
"path": "packages/ql3-cluster-control",
+1
View File
@@ -84,6 +84,7 @@
"test:postgres-backup-prompt-output-recovery-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-postgres-prompt-output-recovery-live-contract.cjs",
"audit:cluster-deployment:ql3": "node scripts/ql3-cluster-deployment-audit.cjs",
"audit:cluster-copilot-console:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs",
"audit:cluster-copilot-console-distribution:ql3": "node scripts/ql3-cluster-copilot-console-distribution-audit.cjs",
"test:cluster-admin-product-live:ql3": "node scripts/ql3-cluster-admin-product-live-contract.cjs",
"audit:provider-credential-management-deployment:ql3": "node scripts/ql3-provider-credential-management-deployment-audit.cjs",
"audit:provider-credential-test-deployment:ql3": "node scripts/ql3-provider-credential-test-deployment-audit.cjs",
+7 -4
View File
@@ -16,10 +16,13 @@ Host/Origin, no-store responses and a closed CSP, renders model text only via
`textContent`, and keeps diagnose/cancel, polling, cache, WebSocket,
ServiceWorker and legacy session authority absent.
The reviewed operator-workstation setup, private-file ceremony, preflight and
session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Do not expose the Console
through a container port mapping, Kubernetes workload or shared network.
The reviewed operator-workstation setup, private-file ceremony, release
verification, preflight and session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
loopback directly. The signed Admin OCI also carries an exact launcher which
uses a container-internal listener only with a fixed publication on host
`127.0.0.1`; arbitrary port mappings, Kubernetes workloads and shared-network
listeners remain forbidden.
The admin role can append Identity/API Credential mutations and their security
audit in one serializable transaction, and can perform bounded read-only audit
@@ -18,8 +18,9 @@ const USAGE = [
'Usage:',
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
'',
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
'The browser session key remains in a separate owner-private 0600 file.',
].join('\n');
@@ -27,6 +28,9 @@ interface ClusterCopilotConsoleCliArguments {
readonly check: boolean;
readonly configFile: string;
readonly credentialFile: string;
readonly networkBoundary:
| 'host-loopback'
| 'container-published-loopback';
readonly sessionFile: string;
readonly port: number;
}
@@ -71,6 +75,7 @@ export function parseClusterCopilotConsoleCliArguments(
let sessionFile: string | undefined;
let port = 0;
let portSeen = false;
let containerPublishedLoopback = false;
for (let index = 0; index < argv.length; ) {
const current = argv[index];
if (current === '--check' && !check) {
@@ -78,6 +83,14 @@ export function parseClusterCopilotConsoleCliArguments(
index += 1;
continue;
}
if (
current === '--container-published-loopback' &&
!containerPublishedLoopback
) {
containerPublishedLoopback = true;
index += 1;
continue;
}
const config = argumentValue(argv, index, '--config');
if (config) {
if (configFile !== undefined) return usageFailure();
@@ -121,7 +134,8 @@ export function parseClusterCopilotConsoleCliArguments(
configFile === undefined ||
credentialFile === undefined ||
sessionFile === undefined ||
(check && port !== 0)
(containerPublishedLoopback && port === 0) ||
(!containerPublishedLoopback && check && port !== 0)
) {
return usageFailure();
}
@@ -129,6 +143,9 @@ export function parseClusterCopilotConsoleCliArguments(
check,
configFile,
credentialFile,
networkBoundary: containerPublishedLoopback
? 'container-published-loopback'
: 'host-loopback',
sessionFile,
port,
});
@@ -178,7 +195,8 @@ async function main(): Promise<void> {
component: 'qinglong3-cluster-copilot-console',
event: 'preflight_checked',
ready: readiness.ready,
listenAddress: '127.0.0.1',
networkBoundary: parsed.networkBoundary,
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
@@ -203,6 +221,7 @@ async function main(): Promise<void> {
});
},
}),
networkBoundary: parsed.networkBoundary,
port: parsed.port,
sessionDigest,
});
@@ -213,7 +232,8 @@ async function main(): Promise<void> {
component: 'qinglong3-cluster-copilot-console',
event: 'started',
origin: server.origin,
listenAddress: '127.0.0.1',
networkBoundary: parsed.networkBoundary,
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
@@ -39,10 +39,15 @@ export interface ClusterCopilotConsoleExecutor {
export interface ClusterCopilotConsoleServerOptions {
readonly assets: Readonly<ClusterCopilotConsoleAssets>;
readonly executor: ClusterCopilotConsoleExecutor;
readonly networkBoundary?: ClusterCopilotConsoleNetworkBoundary;
readonly port: number;
readonly sessionDigest: Buffer;
}
export type ClusterCopilotConsoleNetworkBoundary =
| 'host-loopback'
| 'container-published-loopback';
export interface ClusterCopilotConsoleServer {
readonly origin: string;
close(): Promise<void>;
@@ -297,13 +302,16 @@ function remoteFailure(
export async function startClusterCopilotConsoleServer(
options: ClusterCopilotConsoleServerOptions,
): Promise<Readonly<ClusterCopilotConsoleServer>> {
const record = exactObject(options, [
'assets',
'executor',
'port',
'sessionDigest',
]);
const optionKeys = ['assets', 'executor', 'port', 'sessionDigest'];
if (Object.hasOwn(options, 'networkBoundary')) {
optionKeys.push('networkBoundary');
}
const record = exactObject(options, optionKeys);
const assets = exactObject(record.assets, ['css', 'html', 'javascript']);
const networkBoundary =
record.networkBoundary === undefined
? 'host-loopback'
: record.networkBoundary;
if (
typeof assets.html !== 'string' ||
assets.html.length < 1 ||
@@ -317,6 +325,10 @@ export async function startClusterCopilotConsoleServer(
!Number.isSafeInteger(record.port) ||
((record.port as number) !== 0 &&
((record.port as number) < 1_024 || (record.port as number) > 65_535)) ||
(networkBoundary !== 'host-loopback' &&
networkBoundary !== 'container-published-loopback') ||
(networkBoundary === 'container-published-loopback' &&
(record.port as number) === 0) ||
!Buffer.isBuffer(record.sessionDigest) ||
(record.sessionDigest as Buffer).byteLength !== 32
) {
@@ -324,6 +336,8 @@ export async function startClusterCopilotConsoleServer(
}
const sessionDigest = Buffer.from(record.sessionDigest as Buffer);
const executor = record.executor as ClusterCopilotConsoleExecutor;
const listenAddress =
networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0';
let expectedOrigin = '';
let inFlight = 0;
let closed = false;
@@ -461,7 +475,7 @@ export async function startClusterCopilotConsoleServer(
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(record.port as number, '127.0.0.1', () => {
server.listen(record.port as number, listenAddress, () => {
server.off('error', reject);
resolve();
});
@@ -1,6 +1,6 @@
const assert = require('node:assert/strict');
const { randomBytes } = require('node:crypto');
const { request: httpRequest } = require('node:http');
const { createServer, request: httpRequest } = require('node:http');
const { mkdtemp, mkdir, cp, writeFile } = require('node:fs/promises');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
@@ -19,6 +19,7 @@ const {
normalizeClusterCopilotConsoleReadRequest,
} = require('../dist/copilot-console/contracts.js');
const {
ClusterCopilotConsoleConfigurationError,
clusterCopilotConsoleSessionDigest,
startClusterCopilotConsoleServer,
} = require('../dist/copilot-console/server.js');
@@ -165,6 +166,20 @@ async function fixture(execute = async () => inspection()) {
};
}
async function unusedPort() {
const probe = createServer();
await new Promise((resolve, reject) => {
probe.once('error', reject);
probe.listen(0, '127.0.0.1', resolve);
});
const address = probe.address();
assert.notEqual(typeof address, 'string');
assert.notEqual(address, null);
const port = address.port;
await new Promise((resolve) => probe.close(resolve));
return port;
}
test('normalizes only the two read operations into the shared client contract', () => {
assert.deepEqual(
clusterCopilotConsoleClientCommand(
@@ -244,6 +259,30 @@ test('serves an immutable same-origin shell with a closed browser policy', async
assert.equal(javascript.headers['content-type'], 'text/javascript; charset=utf-8');
});
test('allows only an explicit fixed-port container listener behind host loopback publication', async (t) => {
const token = randomBytes(32).toString('base64url');
await assert.rejects(
startClusterCopilotConsoleServer({
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
executor: { execute: async () => inspection() },
networkBoundary: 'container-published-loopback',
port: 0,
sessionDigest: clusterCopilotConsoleSessionDigest(token),
}),
ClusterCopilotConsoleConfigurationError,
);
const server = await startClusterCopilotConsoleServer({
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
executor: { execute: async () => inspection() },
networkBoundary: 'container-published-loopback',
port: await unusedPort(),
sessionDigest: clusterCopilotConsoleSessionDigest(token),
});
t.after(() => server.close());
assert.match(server.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.equal((await request(server.origin)).statusCode, 200);
});
test('keeps the Cluster credential server-side and forwards one exact inspect', async (t) => {
const commands = [];
const { server, headers } = await fixture(async (command) => {
@@ -163,8 +163,9 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
'Usage:',
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
'',
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
'The browser session key remains in a separate owner-private 0600 file.',
].join('\n');
assert.deepEqual(await runCli(['--help']), {
@@ -209,7 +210,8 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
component: 'qinglong3-cluster-copilot-console',
event: 'preflight_checked',
ready: true,
listenAddress: '127.0.0.1',
networkBoundary: 'host-loopback',
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
@@ -249,6 +251,8 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.deepEqual(started.operations, ['inspect', 'output']);
assert.equal(started.mutation, false);
assert.equal(started.networkBoundary, 'host-loopback');
assert.equal(started.publishedHostAddress, '127.0.0.1');
const shell = await get(started.origin);
assert.equal(shell.statusCode, 200);
assert.match(shell.body, /Cluster field console/);
@@ -259,3 +263,19 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
});
assert.deepEqual(result, { status: 0, signal: null });
});
test('container mode requires an explicit publish port before any authority read', async () => {
const result = await runCli([
'--container-published-loopback',
'--config',
'/private/client.json',
'--credential',
'/private/credential',
'--session',
'/private/session',
]);
assert.equal(result.status, 64);
assert.equal(result.stdout, '');
assert.match(result.stderr, /container-published-loopback/);
assert.doesNotMatch(result.stderr, /\/private\//);
});
@@ -1,6 +1,6 @@
'use strict';
const { execFileSync } = require('node:child_process');
const { execFileSync, spawnSync } = require('node:child_process');
const { resolve } = require('node:path');
const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/u;
@@ -63,6 +63,18 @@ function docker(args, options = {}) {
});
}
function dockerLogs(container) {
const result = spawnSync('docker', ['logs', container], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.error || result.status !== 0) {
fail('published Console logs are unavailable');
}
return `${result.stdout}${result.stderr}`;
}
function runImage(image, args) {
return docker([
'run',
@@ -301,6 +313,174 @@ child.stdout.on('data', (chunk) => {
}
}
function runPublishedConsoleContract(image) {
const suffix = `${process.pid}-${Date.now()}`;
const network = `ql3-console-live-${suffix}`;
const container = `ql3-console-live-${suffix}`;
const containerPort = Number(
execFileSync(
process.execPath,
[
'-e',
"const s=require('node:net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close();});",
],
{ encoding: 'utf8', timeout: 5_000 },
),
);
if (!Number.isSafeInteger(containerPort) || containerPort < 1_024) {
fail('published Console test port is invalid');
}
const source = String.raw`
const { spawn } = require('node:child_process');
const { statSync, writeFileSync } = require('node:fs');
const { rootCertificates } = require('node:tls');
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
const share = '/opt/qinglong/share/ql3-copilot-console';
for (const [file, mode] of [['docker-loopback.sh', 0o555], ['verify-release.sh', 0o555], ['README.md', 0o444], ['client-config.example.json', 0o444], ['host-environment.example.json', 0o444]]) {
if ((statSync(share + '/' + file).mode & 0o777) !== mode) process.exit(51);
}
writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 });
writeFileSync('/tmp/client.json', JSON.stringify({ schema: 'qinglong/cluster-copilot-client-config@v1', endpoint: 'https://localhost:65535/', servername: 'localhost', caFile: '/tmp/ca.pem', requestTimeoutMs: 1000 }), { mode: 0o600 });
writeFileSync('/tmp/credential', 'ql3c_console_' + Buffer.alloc(32, 7).toString('base64url'), { mode: 0o600 });
writeFileSync('/tmp/session', Buffer.alloc(32, 11).toString('base64url'), { mode: 0o600 });
const child = spawn(process.execPath, [facade, 'copilot-console', '--container-published-loopback', '--port=${containerPort}', '--config', '/tmp/client.json', '--credential', '/tmp/credential', '--session', '/tmp/session'], { stdio: 'inherit' });
child.once('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
process.once('SIGTERM', () => child.kill('SIGTERM'));
process.once('SIGINT', () => child.kill('SIGINT'));
`;
let createdNetwork = false;
let createdContainer = false;
try {
docker(['network', 'create', '--driver', 'bridge', network]);
createdNetwork = true;
docker([
'run',
'--detach',
'--name',
container,
'--read-only',
'--network',
network,
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'10001:10001',
'--pids-limit',
'32',
'--memory',
'192m',
'--cpus',
'0.25',
'--stop-timeout',
'3',
'--tmpfs',
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
'--publish',
`127.0.0.1:${containerPort}:${containerPort}/tcp`,
'--entrypoint',
'node',
image,
'-e',
source,
]);
createdContainer = true;
const waitArray = new Int32Array(new SharedArrayBuffer(4));
let logs = '';
for (let attempt = 0; attempt < 200; attempt += 1) {
logs = dockerLogs(container);
if (logs.includes('"event":"started"')) break;
Atomics.wait(waitArray, 0, 0, 25);
}
const startedLine = logs
.split('\n')
.find((line) => line.includes('"event":"started"'));
if (!startedLine) {
const state = JSON.parse(docker(['inspect', container]))[0]?.State;
let terminalCode = 'absent';
for (const line of logs.trim().split('\n').reverse()) {
try {
const fact = JSON.parse(line);
terminalCode = fact.code ?? fact.event ?? 'unknown';
break;
} catch {}
}
fail(
`published Console did not start (running=${String(state?.Running)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
);
}
let started;
try {
started = JSON.parse(startedLine);
} catch {
fail('published Console start fact is invalid');
}
if (
started?.origin !== `http://127.0.0.1:${containerPort}` ||
started?.networkBoundary !== 'container-published-loopback' ||
started?.publishedHostAddress !== '127.0.0.1'
) {
fail('published Console boundary fact drifted');
}
const published = docker([
'port',
container,
`${containerPort}/tcp`,
]).trim();
const publishedMatch = /^127\.0\.0\.1:([1-9][0-9]{0,4})$/u.exec(
published,
);
if (!publishedMatch) fail('published Console escaped host loopback');
const origin = `http://127.0.0.1:${publishedMatch[1]}`;
const probe = execFileSync(
process.execPath,
[
'-e',
"require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field console'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));",
origin,
],
{ encoding: 'utf8', timeout: 5_000 },
);
const probeFact = JSON.parse(probe);
if (probeFact.status !== 200 || probeFact.assets !== true) {
fail('published Console host read drifted');
}
const inspected = JSON.parse(docker(['inspect', container]))[0];
const binding =
inspected?.HostConfig?.PortBindings?.[`${containerPort}/tcp`]?.[0];
if (
inspected?.HostConfig?.ReadonlyRootfs !== true ||
inspected?.HostConfig?.NetworkMode !== network ||
binding?.HostIp !== '127.0.0.1' ||
inspected?.HostConfig?.Privileged !== false ||
!inspected?.HostConfig?.CapDrop?.includes('ALL')
) {
fail('published Console container authority drifted');
}
} finally {
if (createdContainer) {
try {
docker(['stop', '--time', '3', container]);
} catch {}
try {
docker(['rm', '--force', container]);
} catch {}
}
if (createdNetwork) {
try {
docker(['network', 'rm', network]);
} catch {}
}
}
}
function main() {
if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') {
fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required');
@@ -341,6 +521,7 @@ function main() {
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
runOperatorContextContract(image);
runConsoleContract(image);
runPublishedConsoleContract(image);
process.stdout.write(
`${JSON.stringify({
@@ -355,6 +536,8 @@ function main() {
contextReadiness: true,
consoleLoopback: true,
consoleAssets: true,
consolePublishedHostAddress: '127.0.0.1',
consoleDistributionEmbedded: true,
isolation: Object.freeze({
readOnlyRoot: true,
network: 'none',
@@ -106,7 +106,9 @@ function auditClusterCopilotConsole(options = {}) {
'credential',
]);
expectFragments(CONSOLE_ROOT + '/server.ts', [
"server.listen(record.port as number, '127.0.0.1'",
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
"networkBoundary === 'container-published-loopback'",
'server.listen(record.port as number, listenAddress',
'request.headers.origin !== expectedOrigin',
"request.headers.host !== expectedOrigin.slice('http://'.length)",
'maximumConcurrentRequests: 2',
@@ -117,7 +119,6 @@ function auditClusterCopilotConsole(options = {}) {
"'cache-control': 'no-store'",
]);
rejectFragments(CONSOLE_ROOT + '/server.ts', [
"'0.0.0.0'",
'createSecureServer',
'WebSocket',
'set-cookie',
@@ -133,6 +134,8 @@ function auditClusterCopilotConsole(options = {}) {
"'private'",
'validateClusterCopilotClientCredentialFile',
"clusterCredential: 'server_only'",
"networkBoundary: parsed.networkBoundary",
"publishedHostAddress: '127.0.0.1'",
"operations: ['inspect', 'output']",
'mutation: false',
]);
@@ -195,8 +198,12 @@ function auditClusterCopilotConsole(options = {}) {
"started.event !== 'started'",
"body.includes('Cluster field console')",
'runConsoleContract(image);',
'function runPublishedConsoleContract(image)',
'runPublishedConsoleContract(image);',
'consoleLoopback: true',
'consoleAssets: true',
"consolePublishedHostAddress: '127.0.0.1'",
'consoleDistributionEmbedded: true',
]);
let manifest;
@@ -0,0 +1,241 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const FILES = Object.freeze({
launcher: 'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
verifier: 'deploy/console/ql3-cluster-copilot/verify-release.sh',
environment:
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
workflow: '.github/workflows/ql3-image-release.yml',
cli: 'packages/ql3-cluster-admin/src/copilot-console/cli.ts',
server: 'packages/ql3-cluster-admin/src/copilot-console/server.ts',
});
function finding(code, target, detail) {
return Object.freeze({ code, target, detail });
}
function auditClusterCopilotConsoleDistribution(options = {}) {
const root = options.root || path.resolve(__dirname, '..');
const readFile =
options.readFile ||
((relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'));
const findings = [];
const source = {};
for (const [name, relativePath] of Object.entries(FILES)) {
try {
source[name] = readFile(relativePath);
} catch (error) {
findings.push(
finding(
'QL3_COPILOT_CONSOLE_DISTRIBUTION_FILE_MISSING',
relativePath,
error instanceof Error ? error.name : 'Error',
),
);
}
}
const requireFragments = (name, fragments, code) => {
const contents = source[name];
if (typeof contents !== 'string') return;
for (const fragment of fragments) {
if (!contents.includes(fragment)) {
findings.push(finding(code, FILES[name], fragment));
}
}
};
const rejectFragments = (name, fragments, code) => {
const contents = source[name];
if (typeof contents !== 'string') return;
for (const fragment of fragments) {
if (contents.includes(fragment)) {
findings.push(finding(code, FILES[name], fragment));
}
}
};
requireFragments(
'launcher',
[
'docker run --rm --pull never --init --read-only',
'--network "$network"',
'--cap-drop ALL',
'--security-opt no-new-privileges',
'--user 10001:10001',
'--pids-limit "$pids"',
'--memory "$memory"',
'--cpus "$cpus"',
'--tmpfs /tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
'--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-console,readonly"',
'--publish "127.0.0.1:$port:$port/tcp"',
'--container-published-loopback',
'bridge|default|host|none) fail',
'compact)',
'memory=192m',
'standard)',
'memory=512m',
],
'QL3_COPILOT_CONSOLE_LAUNCHER_CONTRACT_DRIFT',
);
rejectFragments(
'launcher',
['--privileged', '--network host', '/var/run/docker.sock', '--pull always'],
'QL3_COPILOT_CONSOLE_LAUNCHER_AUTHORITY_WIDENED',
);
requireFragments(
'verifier',
[
'qinglong3-cluster-admin@sha256:',
'cosign verify',
'--certificate-identity "$certificate_identity"',
'--certificate-oidc-issuer https://token.actions.githubusercontent.com',
'gh attestation verify "oci://$image"',
'--signer-workflow "$workflow"',
'--source-digest "$source_revision"',
'--source-ref "$source_ref"',
'--deny-self-hosted-runners',
'--bundle-from-oci',
'https://cyclonedx.org/bom',
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
],
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
);
rejectFragments(
'verifier',
[':latest', 'refs/heads/', '--insecure-ignore-tlog', '--certificate-identity-regexp'],
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_WIDENED',
);
let environment;
try {
environment = JSON.parse(source.environment);
} catch (error) {
if (typeof source.environment === 'string') {
findings.push(
finding(
'QL3_COPILOT_CONSOLE_HOST_ENVIRONMENT_INVALID',
FILES.environment,
error instanceof Error ? error.name : 'Error',
),
);
}
}
const expectedEnvironment = {
QL3_COPILOT_CONSOLE_IMAGE:
'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64),
QL3_COPILOT_CONSOLE_PRIVATE_ROOT:
'/absolute/private/ql3-copilot-console',
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-copilot-console-egress',
QL3_COPILOT_CONSOLE_PORT: '5701',
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
};
if (
environment &&
JSON.stringify(environment) !== JSON.stringify(expectedEnvironment)
) {
findings.push(
finding(
'QL3_COPILOT_CONSOLE_HOST_ENVIRONMENT_INVALID',
FILES.environment,
'exact digest, private-root, named-network, port and resource-class keys are required',
),
);
}
requireFragments(
'image',
[
'COPY --chmod=0555 deploy/console/ql3-cluster-copilot/docker-loopback.sh',
'share/ql3-copilot-console/docker-loopback.sh',
'COPY --chmod=0555 deploy/console/ql3-cluster-copilot/verify-release.sh',
'share/ql3-copilot-console/verify-release.sh',
'COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json',
'share/ql3-copilot-console/host-environment.example.json',
],
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
);
requireFragments(
'workflow',
[
'image: admin',
'image_arch: amd64',
'image_arch: arm64',
'cosign sign --yes "${IMAGE}@${DIGEST}"',
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
'gh attestation verify "oci://${IMAGE}@${DIGEST}"',
'--predicate-type "https://cyclonedx.org/bom"',
'--deny-self-hosted-runners',
'--bundle-from-oci',
'Promote only the verified digest to immutable release tags',
],
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
);
requireFragments(
'cli',
[
"'container-published-loopback'",
"publishedHostAddress: '127.0.0.1'",
'(containerPublishedLoopback && port === 0)',
],
'QL3_COPILOT_CONSOLE_NETWORK_BOUNDARY_DRIFT',
);
requireFragments(
'server',
[
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
"networkBoundary === 'container-published-loopback'",
'server.listen(record.port as number, listenAddress',
],
'QL3_COPILOT_CONSOLE_NETWORK_BOUNDARY_DRIFT',
);
const kubernetesRoot = path.join(root, 'deploy/kubernetes');
const pending = [kubernetesRoot];
while (pending.length > 0) {
const directory = pending.pop();
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) pending.push(absolute);
else if (
entry.isFile() &&
/\.ya?ml$/u.test(entry.name) &&
fs.readFileSync(absolute, 'utf8').includes('ql3-copilot-console')
) {
findings.push(
finding(
'QL3_COPILOT_CONSOLE_KUBERNETES_RESIDENT',
path.relative(root, absolute),
'workstation Console must remain outside the Cluster workload graph',
),
);
}
}
}
return Object.freeze({
schemaVersion: 1,
component: 'cluster-copilot-console-distribution',
artifact: 'signed-admin-oci',
architectures: Object.freeze(['amd64', 'arm64']),
hostPublication: '127.0.0.1',
kubernetesResident: false,
additionalWorkspacePackages: 0,
findings: Object.freeze(findings),
compatible: findings.length === 0,
});
}
function main() {
const report = auditClusterCopilotConsoleDistribution();
process.stdout.write(JSON.stringify(report) + '\n');
if (!report.compatible) process.exitCode = 1;
}
if (require.main === module) main();
module.exports = { auditClusterCopilotConsoleDistribution };
@@ -49,7 +49,7 @@ test('fails closed before Docker without explicit opt-in', () => {
assert.equal(result.stderr.includes('spawn'), false);
});
test('binds the live image gate to loopback Console assets and shutdown', () => {
test('binds the live image gate to native and container-published loopback', () => {
const source = fs.readFileSync(script, 'utf8');
assert.match(source, /function runConsoleContract\(image\)/);
assert.match(source, /\[facade, 'copilot-console'/);
@@ -57,4 +57,12 @@ test('binds the live image gate to loopback Console assets and shutdown', () =>
assert.match(source, /runConsoleContract\(image\);/);
assert.match(source, /consoleLoopback: true/);
assert.match(source, /consoleAssets: true/);
assert.match(source, /function runPublishedConsoleContract\(image\)/);
assert.match(
source,
/127\.0\.0\.1:\$\{containerPort\}:\$\{containerPort\}\/tcp/,
);
assert.match(source, /runPublishedConsoleContract\(image\);/);
assert.match(source, /consolePublishedHostAddress: '127\.0\.0\.1'/);
assert.match(source, /consoleDistributionEmbedded: true/);
});
@@ -0,0 +1,111 @@
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const ROOT = path.resolve(__dirname, '../..');
const verifier = path.join(
ROOT,
'deploy/console/ql3-cluster-copilot/verify-release.sh',
);
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(64)}`;
const revision = 'c'.repeat(40);
function fixture(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-admin-verifier-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const bin = path.join(directory, 'bin');
const capture = path.join(directory, 'calls');
fs.mkdirSync(bin, { mode: 0o700 });
for (const command of ['cosign', 'gh']) {
fs.writeFileSync(
path.join(bin, command),
`#!/bin/sh\nprintf '${command}\\n' >> "$QL3_TEST_VERIFY_CALLS"\nprintf 'arg=%s\\n' "$@" >> "$QL3_TEST_VERIFY_CALLS"\n`,
{ mode: 0o700 },
);
}
return {
capture,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH ?? ''}`,
QL3_TEST_VERIFY_CALLS: capture,
},
};
}
function invoke(args, env) {
return spawnSync(verifier, args, {
cwd: ROOT,
env,
encoding: 'utf8',
});
}
test('verifies one signature and three digest-bound GitHub attestations', (t) => {
assert.equal(fs.statSync(verifier).mode & 0o777, 0o755);
const value = fixture(t);
const result = invoke(
[image, 'example/qinglong', revision, 'refs/tags/v3.0.0-alpha.1'],
value.env,
);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
schemaVersion: 1,
component: 'qinglong3-cluster-admin-release-verifier',
signature: true,
provenance: true,
sbom: true,
osVulnerabilityEvidence: true,
compatible: true,
});
const calls = fs.readFileSync(value.capture, 'utf8');
assert.equal((calls.match(/^cosign$/gmu) ?? []).length, 1);
assert.equal((calls.match(/^gh$/gmu) ?? []).length, 3);
for (const required of [
'arg=--certificate-identity',
'arg=https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.1',
'arg=--certificate-oidc-issuer',
'arg=https://token.actions.githubusercontent.com',
`arg=${image}`,
`arg=oci://${image}`,
'arg=--repo',
'arg=example/qinglong',
'arg=--signer-workflow',
'arg=example/qinglong/.github/workflows/ql3-image-release.yml',
'arg=--source-digest',
`arg=${revision}`,
'arg=--source-ref',
'arg=refs/tags/v3.0.0-alpha.1',
'arg=https://cyclonedx.org/bom',
'arg=https://qinglong.dev/attestations/image-os-vulnerability/v1',
'arg=--deny-self-hosted-runners',
'arg=--bundle-from-oci',
]) {
assert.match(calls, new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'));
}
});
test('rejects mutable or source-unbound inputs before invoking trust tools', (t) => {
const value = fixture(t);
for (const args of [
['ghcr.io/example/qinglong3-cluster-admin:latest', 'example/qinglong', revision, 'refs/tags/v3.0.0'],
[image, 'other/qinglong', revision, 'refs/tags/v3.0.0'],
[image, 'example/qinglong', 'short', 'refs/tags/v3.0.0'],
[image, 'example/qinglong', revision, 'refs/heads/next'],
]) {
const rejected = invoke(args, value.env);
assert.equal(rejected.status, 78);
assert.equal(rejected.stdout, '');
assert.deepEqual(JSON.parse(rejected.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-admin-release-verifier',
event: 'verification_failed',
});
assert.equal(fs.existsSync(value.capture), false);
}
});
@@ -0,0 +1,86 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const {
auditClusterCopilotConsoleDistribution,
} = require('../../scripts/ql3-cluster-copilot-console-distribution-audit.cjs');
const ROOT = path.resolve(__dirname, '../..');
function intercept(target, transform) {
return (relativePath) => {
const source = fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
return relativePath === target ? transform(source) : source;
};
}
test('accepts the signed multi-architecture Admin OCI workstation distribution', () => {
assert.deepEqual(auditClusterCopilotConsoleDistribution({ root: ROOT }), {
schemaVersion: 1,
component: 'cluster-copilot-console-distribution',
artifact: 'signed-admin-oci',
architectures: ['amd64', 'arm64'],
hostPublication: '127.0.0.1',
kubernetesResident: false,
additionalWorkspacePackages: 0,
findings: [],
compatible: true,
});
});
test('rejects remote publication and weakened image runtime authority', () => {
for (const transform of [
(source) => source.replace('127.0.0.1:$port:$port/tcp', '0.0.0.0:$port:$port/tcp'),
(source) => source.replace('--cap-drop ALL', '--privileged'),
(source) => source.replace('--network "$network"', '--network host'),
]) {
const report = auditClusterCopilotConsoleDistribution({
root: ROOT,
readFile: intercept(
'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
transform,
),
});
assert.equal(report.compatible, false);
assert.ok(
report.findings.some(({ code }) =>
code.startsWith('QL3_COPILOT_CONSOLE_LAUNCHER_'),
),
);
}
});
test('rejects verifier, embedded artifact and release workflow drift', () => {
const fixtures = [
[
'deploy/console/ql3-cluster-copilot/verify-release.sh',
(source) => source.replace('--deny-self-hosted-runners', ''),
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
],
[
'deploy/containers/ql3-cluster-admin/Dockerfile',
(source) => source.replace('COPY --chmod=0555', 'COPY --chmod=0777'),
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
],
[
'.github/workflows/ql3-image-release.yml',
(source) => source.replace(
'Promote only the verified digest to immutable release tags',
'Promote mutable release tags',
),
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
],
];
for (const [target, transform, code] of fixtures) {
const report = auditClusterCopilotConsoleDistribution({
root: ROOT,
readFile: intercept(target, transform),
});
assert.equal(report.compatible, false);
assert.ok(report.findings.some((finding) => finding.code === code));
}
});
@@ -0,0 +1,143 @@
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const ROOT = path.resolve(__dirname, '../..');
const launcher = path.join(
ROOT,
'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
);
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'a'.repeat(64)}`;
function fixture(t) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-console-launcher-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const privateRoot = path.join(directory, 'private');
const bin = path.join(directory, 'bin');
fs.mkdirSync(privateRoot, { mode: 0o700 });
fs.mkdirSync(bin, { mode: 0o700 });
const capture = path.join(directory, 'docker-args');
fs.writeFileSync(
path.join(bin, 'docker'),
'#!/bin/sh\nprintf \'%s\\n\' "$@" > "$QL3_TEST_DOCKER_ARGS"\n',
{ mode: 0o700 },
);
return {
privateRoot: fs.realpathSync(privateRoot),
capture,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH ?? ''}`,
QL3_TEST_DOCKER_ARGS: capture,
QL3_COPILOT_CONSOLE_IMAGE: image,
QL3_COPILOT_CONSOLE_PRIVATE_ROOT: fs.realpathSync(privateRoot),
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-console-egress',
QL3_COPILOT_CONSOLE_PORT: '5701',
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
},
};
}
function invoke(mode, env) {
return spawnSync(launcher, [mode], {
cwd: ROOT,
env,
encoding: 'utf8',
});
}
test('runs compact preflight without opening or publishing the Console', (t) => {
assert.equal(fs.statSync(launcher).mode & 0o777, 0o755);
const value = fixture(t);
const result = invoke('check', value.env);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '');
assert.equal(result.stderr, '');
assert.deepEqual(
fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n'),
[
'run',
'--rm',
'--pull',
'never',
'--init',
'--read-only',
'--network',
'qinglong3-console-egress',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'10001:10001',
'--pids-limit',
'32',
'--memory',
'192m',
'--cpus',
'0.25',
'--stop-timeout',
'3',
'--tmpfs',
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
'--mount',
`type=bind,src=${value.privateRoot},dst=/var/run/secrets/qinglong3/copilot-console,readonly`,
image,
'copilot-console',
'--container-published-loopback',
'--port=5701',
'--config',
'/var/run/secrets/qinglong3/copilot-console/client.json',
'--credential',
'/var/run/secrets/qinglong3/copilot-console/credential',
'--session',
'/var/run/secrets/qinglong3/copilot-console/session',
'--check',
],
);
});
test('publishes standard serve only on host loopback', (t) => {
const value = fixture(t);
const result = invoke('serve', {
...value.env,
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'standard',
});
assert.equal(result.status, 0, result.stderr);
const args = fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n');
assert.equal(args.includes('--check'), false);
assert.equal(args[args.indexOf('--memory') + 1], '512m');
assert.equal(args[args.indexOf('--cpus') + 1], '1');
assert.equal(args[args.indexOf('--pids-limit') + 1], '64');
assert.equal(
args[args.indexOf('--publish') + 1],
'127.0.0.1:5701:5701/tcp',
);
});
test('rejects mutable, ambient and malformed host inputs before Docker', (t) => {
const value = fixture(t);
for (const environment of [
{ ...value.env, QL3_COPILOT_CONSOLE_IMAGE: 'ghcr.io/example/admin:latest' },
{ ...value.env, QL3_COPILOT_CONSOLE_NETWORK: 'host' },
{ ...value.env, QL3_COPILOT_CONSOLE_PORT: '80' },
{ ...value.env, QL3_COPILOT_CONSOLE_PRIVATE_ROOT: `${value.privateRoot}:rw` },
{ ...value.env, QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'unbounded' },
]) {
const rejected = invoke('serve', environment);
assert.equal(rejected.status, 78);
assert.equal(rejected.stdout, '');
assert.deepEqual(JSON.parse(rejected.stderr), {
schemaVersion: 1,
component: 'qinglong3-cluster-copilot-console-launcher',
event: 'launch_failed',
});
assert.equal(fs.existsSync(value.capture), false);
assert.doesNotMatch(rejected.stderr, /latest|host|private|unbounded/);
}
});