feat(ql3): add explicit cluster observation console

This commit is contained in:
whyour
2026-08-16 06:09:54 +08:00
parent fba8dfb602
commit 7955d55629
20 changed files with 1881 additions and 877 deletions
+16 -7
View File
@@ -1,10 +1,10 @@
# Cluster Copilot read-only Console
# Cluster read-only field Console
This Console is an operator-workstation process, not a resident QingLong
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
`127.0.0.1` port and forwards only a fixed vocabulary of Run, Task, Workflow
and Copilot reads to existing Cluster APIs. It never accepts a browser-provided
URL or method. 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
@@ -46,9 +46,11 @@ 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
cancellation even if a wider credential is supplied.
credential as `credential`. Give the credential only `run.read`, `task.read`
and `artifact.read`; `run.read` covers Run and Workflow observations, while
`task.read` covers Task list/detail and `artifact.read` covers an explicitly
requested Copilot output. The Console has no route for Run/Workflow start,
diagnosis creation or cancellation even if a wider credential is supplied.
Create an independent 256-bit browser session key without placing its value in
argv or an environment variable:
@@ -103,6 +105,13 @@ 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.
The page exposes thirteen exact operations: Copilot `inspect|output`; Run
list/detail/events/steps; Task list/detail; and Workflow list plus Workflow Run
list/detail/events/steps. List responses use 32-row pages and offer an explicit
next-page read only when the upstream cursor says more data exists. There is no
automatic cascade from a list to details, steps or events, so each authority
read remains visible and intentional.
## Run the verified image
Create a dedicated Docker network whose egress is restricted by the host
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(2026-08-16):
- D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operationbrowser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpackedpackage/dependency/Cluster deployment/image release/Console/distribution 审计零 findingworkspace 为 18 package 且无 single-source/shallow package。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。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。
- 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。
@@ -0,0 +1,48 @@
# ADR-0421:显式 Cluster Run、Task 与 Workflow 观察台
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-329、Phase 2
- 扩展:ADR-0419
## 背景
ADR-0419/0420 已冻结 loopback-only Console 与签名 Admin OCI 分发,但人的观察面仍只覆盖 Copilot diagnosis。值班人员要判断“有哪些 Task、某个 Run 走到哪一步、Workflow Run 是否截断或失败”,仍需手工拼接 Cluster API URL。让浏览器获得通用 proxy、任意 path 或 `ql3c_` credential 会直接破坏既有 BFF 边界;为这组 UI/transport 文件再建 workspace package,也会恢复已经清理的薄包和额外制品闭包。
现有 Cluster Control 已拥有 Project-scoped、Policy-checked、有界的 Run list/detail/events/steps、Task list/detail,以及 Plugin Package Workflow list 和 Workflow Run list/detail/events/steps。D-329 不需要新增 route、repository、schema 或数据库读取模型,只需要把既有低敏投影组合成可用、显式且失败关闭的运维产品面。
## 决策
1. 观察面继续归属 `@qinglong/cluster-admin/copilot-console` 和同一个签名 Admin OCIworkspace 保持 18 个 package,不新增浏览器依赖、生产依赖、服务或第二分发物。名称保留以兼容已发布命令,但页面产品语义升级为 Cluster field ledger。
2. BFF 固定开放 13 个只读 operationCopilot `inspect|output`Run list/detail/events/stepsTask list/detailWorkflow list 与 Workflow Run list/detail/events/steps。服务端为每种 operation 生成 exact Cluster API `GET` path/query,浏览器不能提交 URL、method、header、credential、permission 或 response shape。
3. `ql3c_` credential 每次请求从 canonical、current-owner、0600 文件重新读取,TLS 继续固定 1.3、显式 CA/DNS、identity encoding、无 redirect/proxy/compression/connection reuse。新 Project read transport 只接受审核过的 Run/Task/Workflow path grammar,并明确拒绝 start/cancellation、绝对 URL、路径穿越与未知 query。
4. Run 与 Workflow 共用 `run.read`Task 使用 `task.read`Copilot output 继续使用既有 `artifact.read`;部署推荐 credential 只授予这三项 read permission。UI 即使收到权限更宽的 credential,也没有 mutation route 或通用转发器。
5. list 默认每页 32、上限 64;只有响应明确 `hasMore|truncated` 且携带下一 cursor 时,页面才出现“显式读取下一页”。读取列表不会自动读取 detail/steps/events,读取 Run 也不会自动级联。没有 poller、watcher、SSE、WebSocket、retry、queue、cache 或后台 timer。
6. 页面采用“现场证据账本”而非通用 Dashboard:每次人工读取形成一条仅存于页面内存的按时序 evidence,所有远端值和模型文本都用 `textContent` 写入。reload/pagehide 清空 session 与账本;不使用 cookie、local/session storage、IndexedDB、ServiceWorker、外部 asset/font/CDN。
7. BFF 保留 ADR-0419 的 exact Host/Origin/session、default-deny CSP、no-store、4 KiB request、约 2 MiB response、2 in-flight、16 connections 与第三请求即时 429。未知 route、route/operation mismatch、额外字段、cursor 半对、非法 ID/UUID/limit 一律在上游调用前失败关闭。
8. Console 仍是短生命周期 operator-workstation process,不成为 Kubernetes Deployment/Ingress/sidecar 或共享 LAN listener。它不进入 Edge/Standalone、Local API/MCP 或 Cluster Control/AI 默认 closure;路由设备的默认制品必须保持不变。
## 不选择
- **浏览器通用反向代理**:即使只允许 GET,也会把未来新增 path 静默暴露给页面,无法证明 operation vocabulary 与权限边界。
- **扩展 Copilot command schema**Run/Task/Workflow 是普通 Cluster observation,不应伪装成 diagnosis commandBFF executor 在内部按领域分派。
- **复制第二套 TLS/credential client**:会产生配置、TOCTOU、header、body ceiling 与错误投影漂移;D-329 复用同一 Project API authority transport。
- **自动加载详情或实时刷新**:扩大读取次数与资源占用,也让值班人员无法区分 durable fact 与页面自行触发的背景读。
- **回接 2.x Web 或新建 package**:前者恢复 legacy session/controller/proxy,后者为单一消费者制造薄包和额外发布矩阵。
## 验收
1. contract 单测覆盖全部 13 个 operation、exact field set、cursor pair、ID/package/workflow/UUID/limit、固定 path/query 与 mutation/absolute URL 拒绝。
2. TLS 端到端覆盖 owner-private credential、TLS 1.3、Bearer、GET-only、request-ID exact matching、远端低敏错误与 credential buffer 清理;既有 Copilot client/MCP/product CLI 回归必须保持兼容。
3. Console server/CLI 覆盖 Host/Origin/session、route confusion、无任意 path、并发即时 429、digest-bound assets、container-published-loopback 和 13-operation manifest。
4. 真实浏览器覆盖 session unlock、Run/Task/Workflow/Copilot 手动读取、显式下一页、恶意内容纯文本、窄屏/键盘与零 console error;真实 Admin image 复验相同页面、非 root、read-only/no-capability/no-new-privileges 与 host-loopback publication。
5. Console、distribution、package、dependency、Edge import、Cluster deployment 审计零 finding18-package clean build/test、Cluster Admin、backend、14 档 Local artifact 全部通过后,本 ADR 才转为 Accepted 并进行 D-329 阶段提交。
## 接受证据
- 13-operation contract、Console server/CLI 与 TLS client 定向门 23/23Cluster Admin 378 pass、3 条件 skip;完整 18-package test 退出 0backend 1,223 pass、2 条件 skip、0 fail。
- 真实浏览器完成 Run、Task、Workflow 三类 BFF 读取、Task 显式下一页、恶意 HTML 纯文本、390×844 无横向溢出和 0 error/0 warning;验收同时发现并修正了 `[hidden]` 被 panel layout 覆盖的问题。
- 真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes;在 non-root `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB、32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。
- Console/distribution/package/dependency/Cluster deployment/image release 审计零 finding。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpackedworkspace 保持 18 package`singleSourcePackages=[]``shallowSourcePackages=[]`
- 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。
- 本 Gate 不修改 schema、migration、SQL、role、Pool、连接或 HA 拓扑,因此不重复消耗 PostgreSQL HA 现场门,继续引用 D-323 的 PostgreSQL 18.6 arm64 142/142 与 timeline `1→2` 基线。
+1
View File
@@ -424,6 +424,7 @@
| [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 |
| [ADR-0421](./ADR-0421-explicit-cluster-run-task-workflow-observation-console.md) | 显式 Cluster Run、Task 与 Workflow 观察台 | Accepted |
## 规则
+10 -8
View File
@@ -1,20 +1,22 @@
# `@qinglong/cluster-admin`
This private QingLong 3.0 package owns explicit cluster operations and the
bounded Cluster Copilot MCP and Console product surfaces.
bounded Cluster Copilot MCP plus Cluster field Console product surfaces.
Database/Kubernetes administration remains short-lived and requires distinct
purpose-bound authority. The MCP subpath has only the remote API client; the
Console is a loopback-only read BFF serving digest-bound static assets. Neither
opens database or Kubernetes authority, enters the legacy 2.x Web application,
or resides in `cluster-control`.
The Console accepts only `inspect` and explicit `output` reads. Its Cluster
API credential stays in a canonical owner-private file and is reread for each
upstream request; browser JavaScript receives only a separate session token
which cannot call Cluster APIs. It binds `127.0.0.1`, enforces exact
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 Console accepts thirteen exact Run, Task, Workflow and Copilot reads. The
browser cannot provide an upstream path or HTTP method, and every list page and
detail/evidence read requires an explicit click. Its Cluster API credential
stays in a canonical owner-private file and is reread for each upstream
request; browser JavaScript receives only a separate session token which cannot
call Cluster APIs. It binds `127.0.0.1`, enforces exact Host/Origin, no-store
responses and a closed CSP, renders all returned data only via `textContent`,
and keeps start/diagnose/cancel, polling, cache, WebSocket, ServiceWorker and
legacy session authority absent.
The reviewed operator-workstation setup, private-file ceremony, release
verification, preflight and session lifecycle are documented in
@@ -1,21 +1,18 @@
:root {
--paper: #f3f6f1;
--paper-deep: #e7ede6;
--ink: #17231d;
--muted: #607067;
--line: #cbd5cc;
--moss: #2f654d;
--moss-soft: #dce9df;
--amber: #c98724;
--plum: #69556d;
--rust: #a84f38;
--white: #fbfdf9;
--shadow: 0 24px 70px rgba(31, 52, 40, 0.12);
font-family:
"Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC",
"Hiragino Sans GB", sans-serif;
--porcelain: #eef3f5;
--paper: #f8faf9;
--ink: #152329;
--muted: #607079;
--line: #bdcbd0;
--cyan: #087f8c;
--cyan-soft: #d7ecee;
--signal: #df6538;
--violet: #655f87;
--error: #a13f32;
--white: #ffffff;
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', sans-serif;
color: var(--ink);
background: var(--paper);
background: var(--porcelain);
font-synthesis: none;
}
@@ -23,19 +20,25 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html {
min-width: 320px;
background:
linear-gradient(90deg, rgba(47, 101, 77, 0.035) 1px, transparent 1px)
0 0 / 24px 24px,
var(--paper);
background: linear-gradient(
90deg,
rgba(8, 127, 140, 0.035) 1px,
transparent 1px
)
0 0 / 28px 28px,
var(--porcelain);
}
body {
margin: 0;
min-height: 100vh;
}
button,
input {
font: inherit;
@@ -43,15 +46,13 @@ input {
button {
border: 1px solid var(--ink);
border-radius: 3px;
padding: 0.78rem 1rem;
border-radius: 2px;
padding: 0.72rem 0.85rem;
color: var(--ink);
background: transparent;
cursor: pointer;
transition:
transform 140ms ease,
background-color 140ms ease,
color 140ms ease;
transition: transform 130ms ease, background-color 130ms ease,
color 130ms ease;
}
button:hover:not(:disabled) {
@@ -59,22 +60,19 @@ button:hover:not(:disabled) {
background: var(--ink);
color: var(--white);
}
button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
button.primary {
background: var(--moss);
border-color: var(--moss);
border-color: var(--cyan);
background: var(--cyan);
color: var(--white);
}
button:focus-visible,
input:focus-visible,
pre:focus-visible {
outline: 3px solid color-mix(in srgb, var(--amber) 72%, white);
outline: 3px solid color-mix(in srgb, var(--signal) 72%, white);
outline-offset: 3px;
}
@@ -83,15 +81,14 @@ input {
border: 0;
border-bottom: 1px solid var(--ink);
border-radius: 0;
padding: 0.72rem 0;
padding: 0.64rem 0;
color: var(--ink);
background: transparent;
}
input::placeholder {
color: #91a097;
color: #8a9ba2;
}
.skip-link {
position: fixed;
left: 1rem;
@@ -102,477 +99,395 @@ input::placeholder {
background: var(--ink);
color: white;
}
.skip-link:focus {
transform: translateY(0);
}
.shell {
width: min(1440px, 100%);
width: min(1500px, 100%);
margin: 0 auto;
min-height: 100vh;
padding: 2rem clamp(1rem, 3vw, 3.5rem) 1.25rem;
padding: 1.6rem clamp(0.8rem, 3vw, 3rem) 1rem;
display: flex;
flex-direction: column;
}
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
padding-bottom: 1.6rem;
padding-bottom: 1.4rem;
border-bottom: 1px solid var(--ink);
}
.brand-lockup {
display: flex;
align-items: center;
gap: 1.2rem;
gap: 1.1rem;
}
.brand-mark {
display: grid;
place-items: center;
width: 3.15rem;
height: 3.15rem;
width: 3rem;
height: 3rem;
border: 1px solid var(--ink);
border-radius: 50%;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
clip-path: polygon(0 0, 82% 0, 100% 18%, 100% 100%, 18% 100%, 0 82%);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-weight: 800;
letter-spacing: -0.09em;
letter-spacing: -0.08em;
}
.eyebrow {
margin: 0 0 0.42rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.69rem;
margin: 0 0 0.38rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.67rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
max-width: 18ch;
font-size: clamp(1.55rem, 3vw, 3rem);
line-height: 0.98;
letter-spacing: -0.055em;
max-width: 20ch;
font-size: clamp(1.5rem, 3vw, 2.85rem);
line-height: 1;
letter-spacing: -0.05em;
}
h2 {
margin-bottom: 0.7rem;
font-size: clamp(1.45rem, 2vw, 2.15rem);
margin-bottom: 0.65rem;
font-size: clamp(1.35rem, 2vw, 2rem);
letter-spacing: -0.035em;
}
h3 {
letter-spacing: -0.025em;
}
.boundary {
min-width: 11rem;
min-width: 13rem;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.22rem 0.52rem;
gap: 0.2rem 0.5rem;
align-items: center;
font-size: 0.76rem;
font-size: 0.74rem;
}
.boundary strong {
grid-column: 2;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-weight: 500;
color: var(--muted);
}
.boundary-dot {
width: 0.65rem;
height: 0.65rem;
width: 0.62rem;
height: 0.62rem;
border-radius: 50%;
background: var(--moss);
box-shadow: 0 0 0 5px var(--moss-soft);
background: var(--cyan);
box-shadow: 0 0 0 5px var(--cyan-soft);
}
.workspace {
flex: 1;
display: grid;
grid-template-columns: minmax(18rem, 0.82fr) minmax(28rem, 1.4fr);
min-height: 680px;
grid-template-columns: minmax(20rem, 0.8fr) minmax(30rem, 1.45fr);
min-height: 720px;
border-bottom: 1px solid var(--ink);
}
.control-deck,
.evidence-panel {
padding: clamp(1.5rem, 3vw, 3.25rem);
padding: clamp(1.35rem, 3vw, 2.8rem);
}
.control-deck {
border-right: 1px solid var(--ink);
background: rgba(251, 253, 249, 0.72);
background: rgba(248, 250, 249, 0.84);
}
.section-heading > p:last-child {
max-width: 44ch;
line-height: 1.7;
max-width: 46ch;
line-height: 1.65;
color: var(--muted);
}
.session-gate {
margin: 2.5rem 0 2rem;
padding: 1.1rem;
border-left: 3px solid var(--plum);
background: color-mix(in srgb, var(--plum) 8%, var(--white));
margin: 2rem 0;
padding: 1rem;
border-left: 3px solid var(--violet);
background: color-mix(in srgb, var(--violet) 7%, var(--white));
}
.inline-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.8rem;
gap: 0.7rem;
}
.field-note {
margin: 0.75rem 0 0;
font-size: 0.78rem;
line-height: 1.55;
color: var(--muted);
}
.target-form {
display: grid;
gap: 0.45rem;
}
.target-form label,
.field-label,
.session-gate label {
margin-top: 0.9rem;
font-size: 0.78rem;
display: block;
margin-top: 0.8rem;
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.025em;
}
.actions {
.field-note {
margin: 0.65rem 0 0;
font-size: 0.76rem;
line-height: 1.5;
color: var(--muted);
}
.mode-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, 1fr);
margin: 1.8rem 0 1.2rem;
border-bottom: 1px solid var(--line);
}
.mode-tab {
border: 0;
border-bottom: 3px solid transparent;
border-radius: 0;
padding-inline: 0.3rem;
color: var(--muted);
}
.mode-tab.active {
border-bottom-color: var(--cyan);
color: var(--ink);
font-weight: 760;
}
.mode-tab:hover:not(:disabled) {
transform: none;
background: var(--cyan-soft);
color: var(--ink);
}
.mode-panel {
display: grid;
gap: 0.7rem;
}
.control-group {
display: grid;
gap: 0.65rem;
margin-top: 1.7rem;
padding: 0.9rem 0 1.05rem;
border-bottom: 1px solid var(--line);
}
.control-title {
display: flex;
align-items: baseline;
gap: 0.55rem;
}
.control-title span {
color: var(--cyan);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.67rem;
}
.button-pair,
.button-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.button-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trust-note {
margin-top: 2.4rem;
padding-top: 1rem;
margin-top: 2rem;
padding-top: 0.9rem;
border-top: 1px solid var(--line);
}
.trust-note span {
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.68rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--plum);
color: var(--violet);
}
.trust-note p {
margin: 0.45rem 0 0;
font-size: 0.8rem;
margin: 0.4rem 0 0;
font-size: 0.78rem;
line-height: 1.55;
color: var(--muted);
}
.evidence-panel {
position: relative;
background: var(--paper-deep);
background: var(--paper);
}
.evidence-header,
.output-heading {
.evidence-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.status-chip {
border: 1px solid currentColor;
border-radius: 999px;
padding: 0.35rem 0.64rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.68rem;
padding: 0.33rem 0.62rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.66rem;
color: var(--muted);
}
.status-chip[data-tone="running"] {
color: var(--amber);
.status-chip[data-tone='busy'] {
color: var(--signal);
}
.status-chip[data-tone="success"] {
color: var(--moss);
.status-chip[data-tone='success'] {
color: var(--cyan);
}
.status-chip[data-tone="failed"] {
color: var(--rust);
.status-chip[data-tone='failed'] {
color: var(--error);
}
.empty-state {
min-height: 450px;
min-height: 480px;
display: grid;
place-content: center;
justify-items: center;
text-align: center;
}
.empty-state p {
max-width: 38ch;
line-height: 1.65;
color: var(--muted);
}
.empty-glyph {
width: 12rem;
height: 7rem;
margin-bottom: 2rem;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 1.3rem;
border-bottom: 1px solid var(--ink);
}
.empty-glyph span {
width: 1px;
background: var(--ink);
transform-origin: bottom;
animation: signal 2.4s ease-in-out infinite;
}
.empty-glyph span:nth-child(1) {
height: 35%;
}
.empty-glyph span:nth-child(2) {
height: 82%;
animation-delay: 180ms;
}
.empty-glyph span:nth-child(3) {
height: 54%;
animation-delay: 360ms;
}
@keyframes signal {
0%,
100% {
transform: scaleY(0.55);
opacity: 0.42;
}
45% {
transform: scaleY(1);
opacity: 1;
}
}
.trace-rail {
list-style: none;
margin: 2.2rem 0;
padding: 0;
.evidence-rail {
width: min(25rem, 80%);
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
align-items: center;
margin-bottom: 2.2rem;
}
.trace-rail li {
.evidence-rail i {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
min-height: 5rem;
}
.trace-rail li:not(:last-child)::after {
content: "";
position: absolute;
top: 0.42rem;
left: 0.45rem;
right: -0.45rem;
height: 1px;
background: var(--moss);
background: var(--line);
}
.trace-node {
.evidence-rail i::before {
content: '';
position: absolute;
left: 0;
top: -0.35rem;
width: 0.7rem;
height: 0.7rem;
border: 1px solid var(--cyan);
background: var(--paper);
transform: rotate(45deg);
}
.ledger {
margin: 0;
padding: 1rem 0 0;
list-style: none;
counter-reset: evidence;
}
.ledger-entry {
position: relative;
z-index: 1;
width: 0.9rem;
height: 0.9rem;
border: 3px solid var(--paper-deep);
border-radius: 50%;
background: var(--moss);
box-shadow: 0 0 0 1px var(--moss);
padding: 1.1rem 0 1.2rem 3.5rem;
border-bottom: 1px solid var(--line);
counter-increment: evidence;
}
.trace-rail small,
.fact-grid dt,
.output-meta dt {
display: block;
margin-bottom: 0.42rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
.ledger-entry::before {
content: counter(evidence, decimal-leading-zero);
position: absolute;
left: 0;
top: 1.15rem;
width: 2.3rem;
color: var(--cyan);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.75rem;
}
.ledger-entry::after {
content: '';
position: absolute;
left: 2.35rem;
top: 1.05rem;
bottom: 1rem;
width: 1px;
background: var(--line);
}
.ledger-entry header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
}
.ledger-entry h3 {
margin-bottom: 0.35rem;
font-size: 1rem;
}
.ledger-entry time {
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.65rem;
letter-spacing: 0.11em;
color: var(--muted);
}
.trace-rail strong {
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.fact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--ink);
border-left: 1px solid var(--ink);
}
.fact-grid div {
min-height: 6.4rem;
padding: 1rem;
border-right: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
}
.fact-grid dd,
.output-meta dd {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.output-panel {
margin-top: 2rem;
padding: 1.2rem;
border: 1px solid var(--plum);
background: var(--white);
box-shadow: var(--shadow);
}
.output-heading span {
border: 1px solid var(--plum);
padding: 0.3rem 0.55rem;
font-size: 0.66rem;
color: var(--plum);
}
.output-panel pre {
.ledger-entry pre {
max-height: 28rem;
overflow: auto;
margin: 1rem 0;
padding: 1rem;
border-left: 3px solid var(--plum);
margin: 0.65rem 0 0;
padding: 0.9rem;
border-left: 2px solid var(--cyan);
background: #e9f0f1;
color: #203138;
white-space: pre-wrap;
word-break: break-word;
font: 0.82rem/1.7 ui-monospace, "SFMono-Regular", Consolas, monospace;
background: #f1eef2;
overflow-wrap: anywhere;
font: 0.72rem/1.55 ui-monospace, 'SFMono-Regular', Consolas, monospace;
}
.output-meta {
display: grid;
grid-template-columns: 0.7fr 0.7fr 1.6fr;
gap: 1rem;
margin: 0;
}
.message {
min-height: 1.4rem;
margin-top: 1.2rem;
font-size: 0.82rem;
margin-top: 1rem;
font-size: 0.8rem;
color: var(--muted);
}
.message[data-tone="error"] {
color: var(--rust);
.message[data-tone='error'] {
color: var(--error);
}
footer {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-top: 1rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
padding-top: 0.9rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.65rem;
color: var(--muted);
}
[hidden] {
display: none !important;
}
@media (max-width: 860px) {
@media (max-width: 900px) {
.masthead,
footer {
flex-direction: column;
}
.boundary {
align-self: flex-start;
}
.workspace {
grid-template-columns: 1fr;
}
.control-deck {
border-right: 0;
border-bottom: 1px solid var(--ink);
}
.trace-rail {
grid-template-columns: 1fr;
gap: 1rem;
}
.trace-rail li {
min-height: 3rem;
}
.trace-rail li:not(:last-child)::after {
left: 0.42rem;
right: auto;
top: 0.8rem;
bottom: -1.45rem;
width: 1px;
height: auto;
.empty-state {
min-height: 300px;
}
}
@media (max-width: 520px) {
.shell {
padding-inline: 0.75rem;
padding-inline: 0.65rem;
}
.brand-lockup {
align-items: flex-start;
}
.brand-mark {
flex: 0 0 auto;
width: 2.7rem;
height: 2.7rem;
display: none;
}
.inline-control,
.button-pair,
.button-grid {
grid-template-columns: 1fr;
}
.mode-tabs {
font-size: 0.78rem;
}
.control-deck,
.evidence-panel {
padding: 1.25rem;
padding: 1.1rem;
}
.inline-control,
.actions,
.fact-grid,
.output-meta {
grid-template-columns: 1fr;
.ledger-entry {
padding-left: 2.8rem;
}
.ledger-entry::after {
left: 1.9rem;
}
}
@@ -582,7 +497,5 @@ footer {
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@@ -1,190 +1,297 @@
"use strict";
'use strict';
(function () {
const commandSchema = "qinglong/cluster-copilot-console-read-request@v1";
const sessionForm = document.getElementById("session-form");
const sessionInput = document.getElementById("session-token");
const targetForm = document.getElementById("target-form");
const inspectButton = document.getElementById("inspect-button");
const outputButton = document.getElementById("output-button");
const emptyState = document.getElementById("empty-state");
const resultView = document.getElementById("result-view");
const outputPanel = document.getElementById("output-panel");
const outputText = document.getElementById("output-text");
const message = document.getElementById("message");
const statusChip = document.getElementById("status-chip");
let sessionToken = "";
let currentTarget = null;
const schema = 'qinglong/cluster-copilot-console-read-request@v1';
const routes = Object.freeze({
inspect: '/api/v1/copilot/inspect',
output: '/api/v1/copilot/output',
run_list: '/api/v1/observe/run-list',
run_read: '/api/v1/observe/run',
run_event_list: '/api/v1/observe/run-events',
run_step_list: '/api/v1/observe/run-steps',
task_list: '/api/v1/observe/task-list',
task_read: '/api/v1/observe/task',
workflow_list: '/api/v1/observe/workflow-list',
workflow_run_list: '/api/v1/observe/workflow-run-list',
workflow_run_read: '/api/v1/observe/workflow-run',
workflow_event_list: '/api/v1/observe/workflow-events',
workflow_step_list: '/api/v1/observe/workflow-steps',
});
const labels = Object.freeze({
inspect: 'Copilot 诊断状态',
output: 'Copilot 诊断内容',
run_list: 'Run 目录',
run_read: 'Run 详情',
run_event_list: 'Run Events',
run_step_list: 'Run Steps',
task_list: 'Task 目录',
task_read: 'Task 当前修订',
workflow_list: 'Workflow 目录',
workflow_run_list: 'Workflow Run 目录',
workflow_run_read: 'Workflow Run 详情',
workflow_event_list: 'Workflow Run Events',
workflow_step_list: 'Workflow Run Steps',
});
const sessionForm = document.getElementById('session-form');
const sessionInput = document.getElementById('session-token');
const controls = document.getElementById('console-controls');
const ledger = document.getElementById('ledger');
const emptyState = document.getElementById('empty-state');
const message = document.getElementById('message');
const statusChip = document.getElementById('status-chip');
let sessionToken = '';
let busy = false;
const setText = function (id, value) {
document.getElementById(id).textContent =
value === null || value === undefined || value === "" ? "—" : String(value);
const value = function (id) {
return document.getElementById(id).value.trim();
};
const setMessage = function (value, tone) {
message.textContent = value;
message.dataset.tone = tone || "neutral";
const requestId = function () {
return 'console-' + crypto.randomUUID();
};
const dateTime = function (value) {
if (!Number.isSafeInteger(value)) return "—";
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "medium",
}).format(new Date(value));
const setMessage = function (text, tone) {
message.textContent = text;
message.dataset.tone = tone || 'neutral';
};
const setBusy = function (busy) {
inspectButton.disabled = busy;
outputButton.disabled =
busy || !currentTarget || currentTarget.outputAvailable !== true;
};
const request = async function (operation, target) {
const response = await fetch("/api/v1/copilot/" + operation, {
method: "POST",
cache: "no-store",
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
Authorization: "QL3-Console " + sessionToken,
},
body: JSON.stringify({
schema: commandSchema,
operation: operation,
projectId: target.projectId,
sourceRunId: target.sourceRunId,
requestId: target.requestId,
}),
const setBusy = function (next) {
busy = next;
document.querySelectorAll('[data-read]').forEach(function (button) {
button.disabled = next;
});
const body = await response.json();
if (!response.ok) {
const error = new Error(
typeof body.code === "string" ? body.code : "console_request_failed",
);
error.code = typeof body.code === "string" ? body.code : "console_request_failed";
throw error;
if (next) {
statusChip.textContent = '读取中';
statusChip.dataset.tone = 'busy';
} else if (statusChip.dataset.tone === 'busy') {
statusChip.textContent = '只读就绪';
statusChip.dataset.tone = 'success';
}
return body;
};
const targetFromForm = function () {
const form = new FormData(targetForm);
const base = function (operation) {
const projectId = value('project-id');
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) {
throw new Error('project_id_invalid');
}
return {
projectId: String(form.get("projectId") || "").trim(),
sourceRunId: String(form.get("sourceRunId") || "").trim(),
requestId: String(form.get("requestId") || "").trim(),
schema: schema,
operation: operation,
projectId: projectId,
requestId: requestId(),
};
};
const renderInspection = function (response) {
const result = response.result;
const fact = result.result;
currentTarget = {
projectId: fact.projectId,
sourceRunId: fact.sourceRunId,
requestId: fact.requestId,
outputAvailable: fact.outputAvailable,
};
emptyState.hidden = true;
resultView.hidden = false;
outputPanel.hidden = true;
outputText.textContent = "";
setText("admitted-at", dateTime(fact.admittedAtMs));
setText("stage", fact.stage || (fact.status === "running" ? "processing" : null));
setText("outcome", fact.outcome || fact.status);
setText("diagnosis-run", fact.diagnosisRunId);
setText("reason", fact.reason);
setText(
"tokens",
fact.usage === null ? null : fact.usage.totalTokens,
);
setText(
"cost",
fact.usage === null || fact.usage.costMicros === null
? null
: "$" + (fact.usage.costMicros / 1000000).toFixed(6),
);
statusChip.textContent = fact.status === "running" ? "诊断进行中" : fact.outcome;
statusChip.dataset.tone =
fact.status === "running"
? "running"
: fact.outcome === "succeeded"
? "success"
: "failed";
outputButton.disabled = fact.outputAvailable !== true;
setMessage(
fact.outputAvailable
? "状态已验证。诊断内容仍未读取。"
: "状态已验证;当前没有可读取的诊断内容。",
);
const payload = function (operation) {
const result = base(operation);
if (operation === 'inspect' || operation === 'output') {
result.sourceRunId = value('source-run-id');
result.requestId = value('diagnosis-request-id');
} else if (operation === 'run_list') {
result.afterCreatedAtMs = null;
result.afterRunId = null;
result.limit = 32;
} else if (operation === 'run_read') {
result.runId = value('run-id');
} else if (operation === 'run_event_list') {
result.runId = value('run-id');
result.afterSequence = 0;
result.limit = 32;
} else if (operation === 'run_step_list') {
result.runId = value('run-id');
result.afterStepKey = null;
result.afterStepRunId = null;
result.limit = 32;
} else if (operation === 'task_list') {
result.afterTaskId = null;
result.limit = 32;
} else if (operation === 'task_read') {
result.taskId = value('task-id');
} else if (operation === 'workflow_list') {
result.packageName = value('package-name');
} else {
result.packageName = value('package-name');
result.workflowId = value('workflow-id');
if (operation === 'workflow_run_list') {
result.afterAdmittedAtMs = null;
result.afterRunId = null;
result.limit = 32;
} else {
result.runId = value('workflow-run-id');
if (operation === 'workflow_event_list') {
result.afterSequence = 0;
result.limit = 32;
} else if (operation === 'workflow_step_list') {
result.afterStepKey = null;
result.afterStepRunId = null;
result.limit = 32;
}
}
}
return result;
};
const renderOutput = function (response) {
const nextPage = function (operation, prior, fact) {
const next = Object.assign({}, prior, { requestId: requestId() });
if (operation === 'run_list' && fact.hasMore === true && fact.next) {
next.afterCreatedAtMs = fact.next.createdAtMs;
next.afterRunId = fact.next.runId;
} else if (
operation === 'task_list' &&
fact.hasMore === true &&
fact.next
) {
next.afterTaskId = fact.next.taskId;
} else if (operation === 'run_event_list' && fact.hasMore === true) {
next.afterSequence = fact.nextAfterSequence;
} else if (
operation === 'run_step_list' &&
fact.hasMore === true &&
fact.next
) {
next.afterStepKey = fact.next.stepKey;
next.afterStepRunId = fact.next.stepRunId;
} else if (
operation === 'workflow_run_list' &&
fact.truncated === true &&
fact.next
) {
next.afterAdmittedAtMs = fact.next.admittedAtMs;
next.afterRunId = fact.next.runId;
} else if (
operation === 'workflow_event_list' &&
fact.truncated === true &&
fact.nextAfterSequence !== null
) {
next.afterSequence = fact.nextAfterSequence;
} else if (
operation === 'workflow_step_list' &&
fact.truncated === true &&
fact.next
) {
next.afterStepKey = fact.next.stepKey;
next.afterStepRunId = fact.next.id;
} else {
return null;
}
return next;
};
const appendEvidence = function (operation, request, response) {
const fact = response.result.result;
outputPanel.hidden = false;
outputText.textContent = fact.result.text;
setText("finish-reason", fact.result.finishReason);
setText("output-bytes", fact.reference.outputBytes);
setText("content-digest", fact.reference.contentDigest);
setMessage("诊断内容已显式读取;请把它当作不可信建议进行复核。");
outputPanel.scrollIntoView({ behavior: "smooth", block: "nearest" });
const entry = document.createElement('li');
entry.className = 'ledger-entry';
const header = document.createElement('header');
const title = document.createElement('h3');
const time = document.createElement('time');
const output = document.createElement('pre');
title.textContent = labels[operation];
time.textContent = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'short',
timeStyle: 'medium',
}).format(new Date());
output.tabIndex = 0;
output.textContent = JSON.stringify(fact, null, 2);
header.append(title, time);
entry.append(header, output);
const next = nextPage(operation, request, fact);
if (next) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = '显式读取下一页';
button.addEventListener('click', function () {
void execute(operation, next);
});
entry.append(button);
}
ledger.prepend(entry);
emptyState.hidden = true;
ledger.hidden = false;
};
sessionForm.addEventListener("submit", function (event) {
const execute = async function (operation, prepared) {
if (busy) return;
setBusy(true);
setMessage('正在读取 ' + labels[operation] + '…');
try {
const body = prepared || payload(operation);
const response = await fetch(routes[operation], {
method: 'POST',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
Authorization: 'QL3-Console ' + sessionToken,
},
body: JSON.stringify(body),
});
const responseBody = await response.json();
if (!response.ok) {
throw new Error(
typeof responseBody.code === 'string'
? responseBody.code
: 'console_request_failed',
);
}
appendEvidence(operation, body, responseBody);
setMessage(
labels[operation] + ' 已加入本页证据账本。刷新页面会清空。',
'success',
);
} catch (error) {
statusChip.textContent = '读取失败';
statusChip.dataset.tone = 'failed';
setMessage(
'无法读取:' +
(error instanceof Error ? error.message : 'console_request_failed'),
'error',
);
} finally {
setBusy(false);
}
};
sessionForm.addEventListener('submit', function (event) {
event.preventDefault();
const candidate = sessionInput.value.trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) {
setMessage("浏览器访问密钥格式无效。", "error");
setMessage('浏览器访问密钥格式无效。', 'error');
return;
}
sessionToken = candidate;
sessionInput.value = "";
sessionInput.value = '';
sessionForm.hidden = true;
targetForm.hidden = false;
setMessage("本次页面已解锁。访问密钥只保留在内存中。");
document.getElementById("project-id").focus();
controls.hidden = false;
statusChip.textContent = '只读就绪';
statusChip.dataset.tone = 'success';
setMessage('本页已解锁;Cluster credential 仍只存在于服务端。', 'success');
document.getElementById('project-id').focus();
});
targetForm.addEventListener("submit", async function (event) {
event.preventDefault();
const target = targetFromForm();
setBusy(true);
setMessage("正在读取 durable status…");
try {
const response = await request("inspect", target);
renderInspection(response);
} catch (error) {
currentTarget = null;
outputPanel.hidden = true;
statusChip.textContent = "读取失败";
statusChip.dataset.tone = "failed";
setMessage("无法读取诊断状态:" + error.code, "error");
} finally {
setBusy(false);
}
document.querySelectorAll('.mode-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.mode-tab').forEach(function (candidate) {
const selected = candidate === tab;
candidate.classList.toggle('active', selected);
candidate.setAttribute('aria-pressed', String(selected));
});
document.querySelectorAll('.mode-panel').forEach(function (panel) {
panel.hidden = panel.id !== tab.dataset.panel;
});
});
});
outputButton.addEventListener("click", async function () {
if (!currentTarget || currentTarget.outputAvailable !== true) return;
setBusy(true);
setMessage("正在显式读取诊断内容…");
try {
const response = await request("output", currentTarget);
renderOutput(response);
} catch (error) {
setMessage("无法读取诊断内容:" + error.code, "error");
} finally {
setBusy(false);
}
document.querySelectorAll('[data-read]').forEach(function (button) {
button.addEventListener('click', function () {
void execute(button.dataset.read);
});
});
window.addEventListener("pagehide", function () {
sessionToken = "";
currentTarget = null;
outputText.textContent = "";
window.addEventListener('pagehide', function () {
sessionToken = '';
ledger.textContent = '';
});
})();
@@ -4,214 +4,136 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta
name="description"
content="QingLong 3.0 Cluster Copilot 只读诊断台"
/>
<title>QingLong 3.0 · Copilot 诊断台</title>
<meta name="description" content="QingLong 3.0 Cluster 只读现场记录台" />
<title>QingLong 3.0 · Cluster 现场记录台</title>
<link rel="stylesheet" href="/app.css" />
<script src="/app.js" defer></script>
</head>
<body>
<a class="skip-link" href="#workspace">跳到诊断工作区</a>
<a class="skip-link" href="#workspace">跳到观察工作区</a>
<div class="shell">
<header class="masthead">
<div class="brand-lockup" aria-label="QingLong 3.0 Cluster Console">
<div class="brand-lockup">
<span class="brand-mark" aria-hidden="true">QL</span>
<div>
<p class="eyebrow">QingLong 3.0 / Cluster field console</p>
<h1>故障诊断,不替你执行</h1>
<p class="eyebrow">QingLong 3.0 / Cluster field ledger</p>
<h1>沿着证据读,不替集群做决定</h1>
</div>
</div>
<div class="boundary" aria-label="当前权限边界">
<span class="boundary-dot" aria-hidden="true"></span>
<span>只读边界</span>
<strong>inspect · output</strong>
<span>本机只读 BFF</span>
<strong>Run · Task · Workflow · Copilot</strong>
</div>
</header>
<main id="workspace" class="workspace">
<section class="control-deck" aria-labelledby="target-title">
<aside class="control-deck" aria-labelledby="control-title">
<div class="section-heading">
<p class="eyebrow">Target coordinates</p>
<h2 id="target-title">定位一次诊断</h2>
<p>
输入已存在的 Project、源 Run 和诊断请求。页面不会创建、取消或重试任何任务。
</p>
<p class="eyebrow">Observation coordinates</p>
<h2 id="control-title">选择要读取的事实</h2>
<p>每次按钮点击只发起一次有界 GET。页面不创建、取消、重试、轮询或缓存任何任务。</p>
</div>
<form id="session-form" class="session-gate" autocomplete="off">
<label for="session-token">浏览器访问密钥</label>
<div class="inline-control">
<input
id="session-token"
name="sessionToken"
type="password"
inputmode="text"
spellcheck="false"
autocomplete="off"
required
pattern="[A-Za-z0-9_\-]{43}"
aria-describedby="session-note"
/>
<button type="submit">解锁本次页面</button>
<input id="session-token" name="sessionToken" type="password" autocomplete="off" spellcheck="false" required pattern="[A-Za-z0-9_\-]{43}" />
<button type="submit">解锁本页</button>
</div>
<p id="session-note" class="field-note">
从独立的 0600 session 文件读取并粘贴;仅保留在当前页面内存,不会发送到 Cluster API。
</p>
<p class="field-note">从独立的 0600 session 文件粘贴;密钥只存在于当前页面内存。</p>
</form>
<form id="target-form" class="target-form" autocomplete="off" hidden>
<label for="project-id">Project</label>
<input
id="project-id"
name="projectId"
type="text"
maxlength="128"
spellcheck="false"
autocomplete="off"
placeholder="project-main"
required
/>
<div id="console-controls" hidden>
<label class="field-label" for="project-id">Project</label>
<input id="project-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="project-main" required />
<label for="run-id">源 Run</label>
<input
id="run-id"
name="sourceRunId"
type="text"
maxlength="36"
spellcheck="false"
autocomplete="off"
placeholder="run_..."
required
/>
<nav class="mode-tabs" aria-label="观察面">
<button type="button" class="mode-tab active" data-panel="runtime-panel" aria-pressed="true">运行态</button>
<button type="button" class="mode-tab" data-panel="workflow-panel" aria-pressed="false">工作流</button>
<button type="button" class="mode-tab" data-panel="copilot-panel" aria-pressed="false">Copilot</button>
</nav>
<label for="request-id">诊断请求</label>
<input
id="request-id"
name="requestId"
type="text"
maxlength="128"
spellcheck="false"
autocomplete="off"
placeholder="diag-request-..."
required
/>
<section id="runtime-panel" class="mode-panel">
<div class="control-group">
<div class="control-title"><span>01</span><strong>目录</strong></div>
<div class="button-pair">
<button type="button" data-read="task_list">读取 Task 列表</button>
<button type="button" data-read="run_list">读取 Run 列表</button>
</div>
</div>
<div class="control-group">
<div class="control-title"><span>02</span><strong>Task 详情</strong></div>
<input id="task-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="task-id" />
<button type="button" data-read="task_read">读取当前修订</button>
</div>
<div class="control-group">
<div class="control-title"><span>03</span><strong>Run 证据链</strong></div>
<input id="run-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="run-id" />
<div class="button-grid">
<button type="button" data-read="run_read">读取 Run</button>
<button type="button" data-read="run_step_list">读取 Steps</button>
<button type="button" data-read="run_event_list">读取 Events</button>
</div>
</div>
</section>
<div class="actions">
<button id="inspect-button" class="primary" type="submit">
读取诊断状态
</button>
<button id="output-button" type="button" disabled>
显式读取诊断内容
</button>
</div>
</form>
<section id="workflow-panel" class="mode-panel" hidden>
<label class="field-label" for="package-name">Package</label>
<input id="package-name" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="ops-package" />
<button type="button" data-read="workflow_list">读取 Workflow 列表</button>
<label class="field-label" for="workflow-id">Workflow</label>
<input id="workflow-id" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="daily-maintenance" />
<button type="button" data-read="workflow_run_list">读取 Workflow Runs</button>
<label class="field-label" for="workflow-run-id">Workflow Run UUID</label>
<input id="workflow-run-id" type="text" maxlength="36" autocomplete="off" spellcheck="false" placeholder="00000000-0000-4000-8000-000000000000" />
<div class="button-grid">
<button type="button" data-read="workflow_run_read">读取 Run</button>
<button type="button" data-read="workflow_step_list">读取 Steps</button>
<button type="button" data-read="workflow_event_list">读取 Events</button>
</div>
</section>
<section id="copilot-panel" class="mode-panel" hidden>
<label class="field-label" for="source-run-id">源 Run</label>
<input id="source-run-id" type="text" maxlength="36" autocomplete="off" spellcheck="false" placeholder="run-source-1" />
<label class="field-label" for="diagnosis-request-id">诊断请求</label>
<input id="diagnosis-request-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="diagnosis-request-1" />
<div class="button-pair">
<button type="button" class="primary" data-read="inspect">读取诊断状态</button>
<button type="button" data-read="output">显式读取诊断内容</button>
</div>
<p class="field-note">模型文本是不可信内容,只会以纯文本进入证据账本。</p>
</section>
</div>
<aside class="trust-note">
<span>Authority note</span>
<p>
Cluster credential 只存在于本机 BFF 的私有文件中。浏览器不能读取它,也不能调用 diagnose 或 cancel。
</p>
<span>Authority boundary</span>
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。</p>
</aside>
</section>
</aside>
<section class="evidence-panel" aria-labelledby="evidence-title">
<div class="evidence-header">
<div>
<p class="eyebrow">Durable evidence</p>
<h2 id="evidence-title">诊断轨迹</h2>
<p class="eyebrow">Explicit read ledger</p>
<h2 id="evidence-title">本页证据账本</h2>
</div>
<span id="status-chip" class="status-chip" data-tone="idle">等待目标</span>
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
</div>
<div id="empty-state" class="empty-state">
<div class="empty-glyph" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<h3>先读取状态,再决定是否查看内容</h3>
<p>
状态响应只包含有界、低敏的 durable facts。模型文本必须由你再次明确选择。
</p>
<div class="evidence-rail" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
<h3>一次读取,一条可复核事实</h3>
<p>先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。</p>
</div>
<div id="result-view" hidden>
<ol class="trace-rail" aria-label="诊断状态时间线">
<li>
<span class="trace-node"></span>
<div>
<small>ADMITTED</small>
<strong id="admitted-at"></strong>
</div>
</li>
<li>
<span class="trace-node"></span>
<div>
<small>STAGE</small>
<strong id="stage"></strong>
</div>
</li>
<li>
<span class="trace-node"></span>
<div>
<small>OUTCOME</small>
<strong id="outcome"></strong>
</div>
</li>
</ol>
<dl class="fact-grid">
<div>
<dt>Diagnosis Run</dt>
<dd id="diagnosis-run"></dd>
</div>
<div>
<dt>Reason</dt>
<dd id="reason"></dd>
</div>
<div>
<dt>Tokens</dt>
<dd id="tokens"></dd>
</div>
<div>
<dt>Settled cost</dt>
<dd id="cost"></dd>
</div>
</dl>
<section id="output-panel" class="output-panel" hidden>
<div class="output-heading">
<div>
<p class="eyebrow">Explicit content read</p>
<h3>诊断内容</h3>
</div>
<span>不可信模型输出</span>
</div>
<pre id="output-text" tabindex="0"></pre>
<dl class="output-meta">
<div>
<dt>Finish reason</dt>
<dd id="finish-reason"></dd>
</div>
<div>
<dt>Output bytes</dt>
<dd id="output-bytes"></dd>
</div>
<div>
<dt>Content digest</dt>
<dd id="content-digest"></dd>
</div>
</dl>
</section>
</div>
<ol id="ledger" class="ledger" aria-live="polite" hidden></ol>
<div id="message" class="message" role="status" aria-live="polite"></div>
</section>
</main>
<footer>
<span>Loopback only · no legacy session · no browser credential</span>
<span>QingLong 3.0 incubation / D-327</span>
<span>Loopback only · explicit reads · zero polling</span>
<span>QingLong 3.0 incubation / D-329</span>
</footer>
</div>
</body>
@@ -75,6 +75,24 @@ export interface ClusterCopilotClientReadiness {
readonly ready: boolean;
}
/**
* Package-internal read transport shared by the Copilot client and the
* loopback operator console. The caller owns the reviewed path vocabulary;
* this boundary still rejects non-Project, mutation and cross-origin targets.
*/
export interface ClusterProjectApiReadExecution {
readonly configFile: string;
readonly credentialFile: string;
readonly path: string;
readonly requestId: string;
}
export interface ClusterProjectApiReadResult {
readonly schemaVersion: 1;
readonly requestId: string;
readonly result: Readonly<Record<string, unknown>>;
}
interface PreparedClusterCopilotClientConfiguration {
readonly endpoint: URL;
readonly servername: string;
@@ -134,6 +152,163 @@ const DNS_NAME =
const API_CREDENTIAL =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const RESPONSE_CODE = /^[a-z][a-z0-9_]{0,127}$/;
const TRANSPORT_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const PROJECT_SEGMENT = '[A-Za-z0-9][A-Za-z0-9._:-]{0,127}';
const PACKAGE_SEGMENT = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
const WORKFLOW_SEGMENT = '[a-z][a-z0-9-]{0,62}';
const UUID_SEGMENT =
'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
const POSITIVE_LIMIT = '(?:[1-9]|[1-5][0-9]|6[0-4])';
const NON_NEGATIVE_INTEGER = '(?:0|[1-9][0-9]{0,15})';
const projectRoot = '/api/v3/projects/' + PROJECT_SEGMENT;
const PROJECT_READ_PATHS = Object.freeze([
new RegExp('^' + projectRoot + '/runs\\?limit=' + POSITIVE_LIMIT + '$'),
new RegExp(
'^' +
projectRoot +
'/runs\\?after_created_at_ms=' +
NON_NEGATIVE_INTEGER +
'&after_run_id=' +
PROJECT_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp('^' + projectRoot + '/runs/' + PROJECT_SEGMENT + '$'),
new RegExp(
'^' +
projectRoot +
'/runs/' +
PROJECT_SEGMENT +
'/events\\?after_sequence=' +
NON_NEGATIVE_INTEGER +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/runs/' +
PROJECT_SEGMENT +
'/steps\\?limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/runs/' +
PROJECT_SEGMENT +
'/steps\\?after_step_key=' +
PROJECT_SEGMENT +
'&after_step_run_id=' +
PROJECT_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp('^' + projectRoot + '/tasks\\?limit=' + POSITIVE_LIMIT + '$'),
new RegExp(
'^' +
projectRoot +
'/tasks\\?after_task_id=' +
PROJECT_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp('^' + projectRoot + '/tasks/' + PROJECT_SEGMENT + '$'),
new RegExp(
'^' + projectRoot + '/packages/' + PACKAGE_SEGMENT + '/workflows$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs\\?limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs\\?after_admitted_at_ms=' +
NON_NEGATIVE_INTEGER +
'&after_run_id=' +
UUID_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'/events\\?after_sequence=' +
NON_NEGATIVE_INTEGER +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'/steps\\?limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'/steps\\?after_step_key=' +
WORKFLOW_SEGMENT +
'&after_step_run_id=' +
UUID_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
]);
function projectReadPathValid(value: string): boolean {
return PROJECT_READ_PATHS.some((pattern) => pattern.test(value));
}
function configurationFailure(): never {
throw new ClusterCopilotClientConfigurationError();
@@ -413,10 +588,7 @@ function requestJson(
} catch (cause) {
throw new ClusterCopilotClientRequestError({ cause });
}
finish(
undefined,
Object.freeze({ ...provisional, body }),
);
finish(undefined, Object.freeze({ ...provisional, body }));
} catch (error) {
finish(
error instanceof ClusterCopilotClientRequestError
@@ -447,10 +619,7 @@ function requestJson(
});
}
function responseRequestId(
response: JsonResponse,
expected: string,
): string {
function responseRequestId(response: JsonResponse, expected: string): string {
const value = response.headers['x-request-id'];
if (
rawHeaderCount(response.rawHeaders, 'x-request-id') !== 1 ||
@@ -462,7 +631,9 @@ function responseRequestId(
return value;
}
function retryAfterSeconds(value: string | string[] | undefined): number | null {
function retryAfterSeconds(
value: string | string[] | undefined,
): number | null {
if (typeof value !== 'string' || !/^[1-9][0-9]{0,3}$/.test(value)) {
return null;
}
@@ -480,7 +651,9 @@ function remoteCode(value: unknown): string {
keys.length < 1 ||
keys.length > 3 ||
keys[0] !== 'code' ||
keys.some((key) => key !== 'code' && key !== 'reason' && key !== 'schema') ||
keys.some(
(key) => key !== 'code' && key !== 'reason' && key !== 'schema',
) ||
typeof record.code !== 'string' ||
!RESPONSE_CODE.test(record.code)
) {
@@ -521,9 +694,7 @@ function readCredentialBytes(credentialFile: string): Buffer {
return bytes;
} catch (error) {
bytes?.fill(0);
if (
error instanceof ClusterCopilotClientConfigurationError
) {
if (error instanceof ClusterCopilotClientConfigurationError) {
throw error;
}
throw new ClusterCopilotClientConfigurationError();
@@ -552,8 +723,7 @@ export async function probeClusterCopilotClientReadiness(
);
const status = readinessStatus(response.body);
const ready = response.statusCode === 200 && status === 'ready';
const notReady =
response.statusCode === 503 && status === 'not_ready';
const notReady = response.statusCode === 503 && status === 'not_ready';
if (!ready && !notReady) throw new ClusterCopilotClientRequestError();
return Object.freeze({ schemaVersion: 1, transport: 'https', ready });
} catch (error) {
@@ -594,10 +764,7 @@ async function executeNormalizedClusterCopilotCommand(
);
if (request.body !== null) {
bodyBytes = Buffer.from(JSON.stringify(request.body), 'utf8');
if (
bodyBytes.length < 2 ||
bodyBytes.length > MAXIMUM_COMMAND_BYTES
) {
if (bodyBytes.length < 2 || bodyBytes.length > MAXIMUM_COMMAND_BYTES) {
return configurationFailure();
}
}
@@ -661,11 +828,7 @@ export async function executeClusterCopilotCommand(
options?: ClusterCopilotClientOptions,
): Promise<Readonly<ClusterCopilotClientResult>> {
const normalizedOptions = validateOptions(options);
const record = exact(execution, [
'command',
'configFile',
'credentialFile',
]);
const record = exact(execution, ['command', 'configFile', 'credentialFile']);
const command = normalizeClusterCopilotClientCommand(record.command);
return executeNormalizedClusterCopilotCommand(
record.configFile as string,
@@ -680,11 +843,7 @@ export async function executeClusterCopilotClient(
options?: ClusterCopilotClientOptions,
): Promise<Readonly<ClusterCopilotClientResult>> {
const normalizedOptions = validateOptions(options);
const record = exact(paths, [
'commandFile',
'configFile',
'credentialFile',
]);
const record = exact(paths, ['commandFile', 'configFile', 'credentialFile']);
let commandBytes: Buffer | undefined;
try {
commandBytes = readCanonicalFile(
@@ -712,3 +871,89 @@ export async function executeClusterCopilotClient(
commandBytes?.fill(0);
}
}
export async function executeClusterProjectApiRead(
execution: ClusterProjectApiReadExecution,
options?: ClusterCopilotClientOptions,
): Promise<Readonly<ClusterProjectApiReadResult>> {
const normalizedOptions = validateOptions(options);
const record = exact(execution, [
'configFile',
'credentialFile',
'path',
'requestId',
]);
if (
typeof record.path !== 'string' ||
record.path.length > 2_048 ||
!projectReadPathValid(record.path) ||
record.path.includes('..') ||
record.path.includes('//') ||
typeof record.requestId !== 'string' ||
!TRANSPORT_REQUEST_ID.test(record.requestId)
) {
throw new ClusterCopilotClientRequestError();
}
let credentialBytes: Buffer | undefined;
let prepared: PreparedClusterCopilotClientConfiguration | undefined;
try {
prepared = prepareConfiguration(record.configFile as string);
credentialBytes = readCredentialBytes(record.credentialFile as string);
const response = await requestJson(
prepared,
Object.freeze({
method: 'GET',
path: record.path,
requestId: record.requestId,
authorization: `Bearer ${credentialBytes.toString('ascii')}`,
}),
MAXIMUM_RESPONSE_BYTES,
normalizedOptions,
);
const requestId = responseRequestId(response, record.requestId);
if (response.statusCode === 200) {
if (
!response.body ||
typeof response.body !== 'object' ||
Array.isArray(response.body)
) {
throw new ClusterCopilotClientRequestError();
}
return Object.freeze({
schemaVersion: 1,
requestId,
result: Object.freeze({
...(response.body as Record<string, unknown>),
}),
});
}
if (response.statusCode >= 400 && response.statusCode <= 599) {
throw new ClusterCopilotClientRemoteError(
response.statusCode,
remoteCode(response.body),
requestId,
retryAfterSeconds(response.headers['retry-after']),
);
}
throw new ClusterCopilotClientRequestError();
} catch (error) {
if (
error instanceof ClusterPluginPackageManagementClientConfigurationError
) {
throw new ClusterCopilotClientConfigurationError();
}
if (
error instanceof ClusterCopilotClientConfigurationError ||
error instanceof ClusterCopilotClientRequestError ||
error instanceof ClusterCopilotClientRemoteError
) {
throw error;
}
throw new ClusterCopilotClientRequestError({
cause: error instanceof Error ? error : undefined,
});
} finally {
credentialBytes?.fill(0);
prepared?.dispose();
}
}
@@ -1,10 +1,5 @@
import { createHash } from 'node:crypto';
import {
lstatSync,
readFileSync,
realpathSync,
type PathLike,
} from 'node:fs';
import { lstatSync, readFileSync, realpathSync, type PathLike } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
import { TextDecoder } from 'node:util';
@@ -28,19 +23,19 @@ const ASSETS = Object.freeze([
name: 'index.html',
field: 'html',
maximumBytes: 32 * 1024,
digest: 'f9fa959f30b92c6b000eecb744ce1d0a7fce822c62b3e17dcf10d4d579a072ac',
digest: 'ed8db5c26dec23e7a5237ef1cd4f5f9c3fc9f5a04a4751b7a3e0ed22dac54c42',
}),
Object.freeze({
name: 'app.css',
field: 'css',
maximumBytes: 64 * 1024,
digest: '200c3405e1e12329fcfb50509b31b19f1567a91552865f039ce0c2de1530032c',
digest: '54234cbba7e110de2f68fad2abd657c334b7e3e80c5d9b4f59bda7e122b4b62f',
}),
Object.freeze({
name: 'app.js',
field: 'javascript',
maximumBytes: 32 * 1024,
digest: 'd60913e725e767d9fa2cb65d60c0eae6d75d219f4bec8aad166bed8b6507fe02',
digest: '61811eac6a89b097b67823ccf49b0736af6494be7b187dbdcbecfc59adb3fce0',
}),
] as const);
@@ -2,13 +2,19 @@
import {
executeClusterCopilotCommand,
executeClusterProjectApiRead,
probeClusterCopilotClientReadiness,
validateClusterCopilotClientConfiguration,
validateClusterCopilotClientCredentialFile,
type ClusterCopilotClientCommand,
} from '../copilot-client/client';
import { readCanonicalFile } from '../management-support/managementClientConfiguration';
import { loadClusterCopilotConsoleAssets } from './assets';
import {
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
clusterCopilotConsoleClientCommand,
clusterCopilotConsoleProjectReadPath,
type ClusterCopilotConsoleReadRequest,
} from './contracts';
import {
clusterCopilotConsoleSessionDigest,
startClusterCopilotConsoleServer,
@@ -28,9 +34,7 @@ interface ClusterCopilotConsoleCliArguments {
readonly check: boolean;
readonly configFile: string;
readonly credentialFile: string;
readonly networkBoundary:
| 'host-loopback'
| 'container-published-loopback';
readonly networkBoundary: 'host-loopback' | 'container-published-loopback';
readonly sessionFile: string;
readonly port: number;
}
@@ -154,11 +158,7 @@ export function parseClusterCopilotConsoleCliArguments(
function readSessionDigest(sessionFile: string): Buffer {
let bytes: Buffer | undefined;
try {
bytes = readCanonicalFile(
sessionFile,
MAXIMUM_SESSION_BYTES,
'private',
);
bytes = readCanonicalFile(sessionFile, MAXIMUM_SESSION_BYTES, 'private');
if (
bytes.some((byte) => byte > 0x7f) ||
!SESSION_TOKEN.test(bytes.toString('ascii'))
@@ -199,7 +199,7 @@ async function main(): Promise<void> {
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
mutation: false,
}) + '\n',
);
@@ -213,11 +213,19 @@ async function main(): Promise<void> {
const server = await startClusterCopilotConsoleServer({
assets,
executor: Object.freeze({
execute(command: Readonly<ClusterCopilotClientCommand>) {
return executeClusterCopilotCommand({
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
if (request.operation === 'inspect' || request.operation === 'output') {
return executeClusterCopilotCommand({
configFile: parsed.configFile,
credentialFile: parsed.credentialFile,
command: clusterCopilotConsoleClientCommand(request),
});
}
return executeClusterProjectApiRead({
configFile: parsed.configFile,
credentialFile: parsed.credentialFile,
command,
path: clusterCopilotConsoleProjectReadPath(request),
requestId: request.requestId,
});
},
}),
@@ -236,7 +244,7 @@ async function main(): Promise<void> {
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
mutation: false,
}) + '\n',
);
@@ -8,16 +8,85 @@ export const CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA =
export const CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA =
'qinglong/cluster-copilot-console-read-response@v1' as const;
export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output';
export const CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS = Object.freeze([
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
] as const);
export interface ClusterCopilotConsoleReadRequest {
export type ClusterCopilotConsoleReadOperation =
(typeof CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS)[number];
interface BaseReadRequest<
Operation extends ClusterCopilotConsoleReadOperation,
> {
readonly schema: typeof CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA;
readonly operation: ClusterCopilotConsoleReadOperation;
readonly operation: Operation;
readonly projectId: string;
readonly sourceRunId: string;
readonly requestId: string;
}
export type ClusterCopilotConsoleReadRequest =
| (BaseReadRequest<'inspect'> & Readonly<{ sourceRunId: string }>)
| (BaseReadRequest<'output'> & Readonly<{ sourceRunId: string }>)
| (BaseReadRequest<'run_list'> &
Readonly<{
afterCreatedAtMs: number | null;
afterRunId: string | null;
limit: number;
}>)
| (BaseReadRequest<'run_read'> & Readonly<{ runId: string }>)
| (BaseReadRequest<'run_event_list'> &
Readonly<{ runId: string; afterSequence: number; limit: number }>)
| (BaseReadRequest<'run_step_list'> &
Readonly<{
runId: string;
afterStepKey: string | null;
afterStepRunId: string | null;
limit: number;
}>)
| (BaseReadRequest<'task_list'> &
Readonly<{ afterTaskId: string | null; limit: number }>)
| (BaseReadRequest<'task_read'> & Readonly<{ taskId: string }>)
| (BaseReadRequest<'workflow_list'> & Readonly<{ packageName: string }>)
| (BaseReadRequest<'workflow_run_list'> &
Readonly<{
packageName: string;
workflowId: string;
afterAdmittedAtMs: number | null;
afterRunId: string | null;
limit: number;
}>)
| (BaseReadRequest<'workflow_run_read'> &
Readonly<{ packageName: string; workflowId: string; runId: string }>)
| (BaseReadRequest<'workflow_event_list'> &
Readonly<{
packageName: string;
workflowId: string;
runId: string;
afterSequence: number;
limit: number;
}>)
| (BaseReadRequest<'workflow_step_list'> &
Readonly<{
packageName: string;
workflowId: string;
runId: string;
afterStepKey: string | null;
afterStepRunId: string | null;
limit: number;
}>);
export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID';
@@ -28,47 +97,305 @@ export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
}
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
const COPILOT_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const WORKFLOW_ID = /^[a-z][a-z0-9-]{0,62}$/;
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function invalid(): never {
throw new InvalidClusterCopilotConsoleReadRequestError();
}
export function normalizeClusterCopilotConsoleReadRequest(
value: unknown,
): Readonly<ClusterCopilotConsoleReadRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalid();
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
function exact(
record: Record<string, unknown>,
operation: ClusterCopilotConsoleReadOperation,
fields: readonly string[],
): void {
const actual = Object.keys(record).sort();
const expected = [
'operation',
'projectId',
'requestId',
'schema',
'sourceRunId',
];
...fields,
].sort();
if (
keys.length !== expected.length ||
keys.some((key, index) => key !== expected[index]) ||
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index]) ||
record.schema !== CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA ||
(record.operation !== 'inspect' && record.operation !== 'output') ||
record.operation !== operation ||
typeof record.projectId !== 'string' ||
!IDENTITY.test(record.projectId) ||
typeof record.sourceRunId !== 'string' ||
!RUN_ID.test(record.sourceRunId) ||
typeof record.requestId !== 'string' ||
!IDENTITY.test(record.requestId)
) {
return invalid();
}
return Object.freeze({
)
invalid();
}
function identifier(value: unknown): value is string {
return typeof value === 'string' && IDENTITY.test(value);
}
function limit(value: unknown): value is number {
return (
Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= 64
);
}
function sequence(value: unknown): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= 0 &&
Number(value) <= 2_147_483_647
);
}
function timestamp(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function common(record: Record<string, unknown>) {
return {
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: record.operation,
projectId: record.projectId,
sourceRunId: record.sourceRunId,
requestId: record.requestId,
projectId: record.projectId as string,
requestId: record.requestId as string,
} as const;
}
function workflowTarget(
record: Record<string, unknown>,
requireRun: boolean,
): boolean {
return (
typeof record.packageName === 'string' &&
PACKAGE_NAME.test(record.packageName) &&
typeof record.workflowId === 'string' &&
WORKFLOW_ID.test(record.workflowId) &&
(!requireRun ||
(typeof record.runId === 'string' && UUID_V4.test(record.runId)))
);
}
export function normalizeClusterCopilotConsoleReadRequest(
value: unknown,
): Readonly<ClusterCopilotConsoleReadRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
const record = value as Record<string, unknown>;
const operation = record.operation;
if (
typeof operation !== 'string' ||
!CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.includes(
operation as ClusterCopilotConsoleReadOperation,
)
)
invalid();
const op = operation as ClusterCopilotConsoleReadOperation;
if (op === 'inspect' || op === 'output') {
exact(record, op, ['sourceRunId']);
if (
typeof record.sourceRunId !== 'string' ||
!COPILOT_RUN_ID.test(record.sourceRunId)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
sourceRunId: record.sourceRunId,
});
}
if (op === 'run_list') {
exact(record, op, ['afterCreatedAtMs', 'afterRunId', 'limit']);
if (
(record.afterCreatedAtMs === null) !== (record.afterRunId === null) ||
(record.afterCreatedAtMs !== null &&
!timestamp(record.afterCreatedAtMs)) ||
(record.afterRunId !== null && !identifier(record.afterRunId)) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
afterCreatedAtMs: record.afterCreatedAtMs as number | null,
afterRunId: record.afterRunId as string | null,
limit: record.limit as number,
});
}
if (op === 'run_read') {
exact(record, op, ['runId']);
if (!identifier(record.runId)) invalid();
return Object.freeze({
...common(record),
operation: op,
runId: record.runId,
});
}
if (op === 'run_event_list') {
exact(record, op, ['afterSequence', 'limit', 'runId']);
if (
!identifier(record.runId) ||
!sequence(record.afterSequence) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
runId: record.runId,
afterSequence: record.afterSequence as number,
limit: record.limit as number,
});
}
if (op === 'run_step_list') {
exact(record, op, ['afterStepKey', 'afterStepRunId', 'limit', 'runId']);
if (
!identifier(record.runId) ||
(record.afterStepKey === null) !== (record.afterStepRunId === null) ||
(record.afterStepKey !== null && !identifier(record.afterStepKey)) ||
(record.afterStepRunId !== null && !identifier(record.afterStepRunId)) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
runId: record.runId,
afterStepKey: record.afterStepKey as string | null,
afterStepRunId: record.afterStepRunId as string | null,
limit: record.limit as number,
});
}
if (op === 'task_list') {
exact(record, op, ['afterTaskId', 'limit']);
if (
(record.afterTaskId !== null && !identifier(record.afterTaskId)) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
afterTaskId: record.afterTaskId as string | null,
limit: record.limit as number,
});
}
if (op === 'task_read') {
exact(record, op, ['taskId']);
if (!identifier(record.taskId)) invalid();
return Object.freeze({
...common(record),
operation: op,
taskId: record.taskId,
});
}
if (op === 'workflow_list') {
exact(record, op, ['packageName']);
if (
typeof record.packageName !== 'string' ||
!PACKAGE_NAME.test(record.packageName)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName,
});
}
if (op === 'workflow_run_list') {
exact(record, op, [
'afterAdmittedAtMs',
'afterRunId',
'limit',
'packageName',
'workflowId',
]);
if (
!workflowTarget(record, false) ||
(record.afterAdmittedAtMs === null) !== (record.afterRunId === null) ||
(record.afterAdmittedAtMs !== null &&
!timestamp(record.afterAdmittedAtMs)) ||
(record.afterRunId !== null &&
(typeof record.afterRunId !== 'string' ||
!UUID_V4.test(record.afterRunId))) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName as string,
workflowId: record.workflowId as string,
afterAdmittedAtMs: record.afterAdmittedAtMs as number | null,
afterRunId: record.afterRunId as string | null,
limit: record.limit as number,
});
}
if (op === 'workflow_run_read') {
exact(record, op, ['packageName', 'runId', 'workflowId']);
if (!workflowTarget(record, true)) invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName as string,
workflowId: record.workflowId as string,
runId: record.runId as string,
});
}
if (op === 'workflow_event_list') {
exact(record, op, [
'afterSequence',
'limit',
'packageName',
'runId',
'workflowId',
]);
if (
!workflowTarget(record, true) ||
!sequence(record.afterSequence) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName as string,
workflowId: record.workflowId as string,
runId: record.runId as string,
afterSequence: record.afterSequence as number,
limit: record.limit as number,
});
}
exact(record, 'workflow_step_list', [
'afterStepKey',
'afterStepRunId',
'limit',
'packageName',
'runId',
'workflowId',
]);
if (
!workflowTarget(record, true) ||
(record.afterStepKey === null) !== (record.afterStepRunId === null) ||
(record.afterStepKey !== null &&
(typeof record.afterStepKey !== 'string' ||
!WORKFLOW_ID.test(record.afterStepKey))) ||
(record.afterStepRunId !== null &&
(typeof record.afterStepRunId !== 'string' ||
!UUID_V4.test(record.afterStepRunId))) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: 'workflow_step_list',
packageName: record.packageName as string,
workflowId: record.workflowId as string,
runId: record.runId as string,
afterStepKey: record.afterStepKey as string | null,
afterStepRunId: record.afterStepRunId as string | null,
limit: record.limit as number,
});
}
@@ -76,6 +403,8 @@ export function clusterCopilotConsoleClientCommand(
request: Readonly<ClusterCopilotConsoleReadRequest>,
): Readonly<ClusterCopilotClientCommand> {
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
if (normalized.operation !== 'inspect' && normalized.operation !== 'output')
invalid();
return Object.freeze({
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
operation: normalized.operation,
@@ -84,3 +413,111 @@ export function clusterCopilotConsoleClientCommand(
requestId: normalized.requestId,
});
}
function encoded(value: string): string {
return encodeURIComponent(value);
}
function query(
entries: readonly (readonly [string, string | number])[],
): string {
return entries.length === 0
? ''
: '?' +
entries
.map(([key, value]) => encoded(key) + '=' + encoded(String(value)))
.join('&');
}
export function clusterCopilotConsoleProjectReadPath(
request: Readonly<ClusterCopilotConsoleReadRequest>,
): string {
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
if (normalized.operation === 'inspect' || normalized.operation === 'output')
invalid();
const project = '/api/v3/projects/' + encoded(normalized.projectId);
if (normalized.operation === 'run_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterCreatedAtMs === null
? []
: [
['after_created_at_ms', normalized.afterCreatedAtMs],
['after_run_id', normalized.afterRunId!],
];
return project + '/runs' + query([...cursor, ['limit', normalized.limit]]);
}
if (normalized.operation === 'run_read')
return project + '/runs/' + encoded(normalized.runId);
if (normalized.operation === 'run_event_list')
return (
project +
'/runs/' +
encoded(normalized.runId) +
'/events' +
query([
['after_sequence', normalized.afterSequence],
['limit', normalized.limit],
])
);
if (normalized.operation === 'run_step_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterStepKey === null
? []
: [
['after_step_key', normalized.afterStepKey],
['after_step_run_id', normalized.afterStepRunId!],
];
return (
project +
'/runs/' +
encoded(normalized.runId) +
'/steps' +
query([...cursor, ['limit', normalized.limit]])
);
}
if (normalized.operation === 'task_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterTaskId === null
? []
: [['after_task_id', normalized.afterTaskId]];
return project + '/tasks' + query([...cursor, ['limit', normalized.limit]]);
}
if (normalized.operation === 'task_read')
return project + '/tasks/' + encoded(normalized.taskId);
const packageRoot = project + '/packages/' + encoded(normalized.packageName);
if (normalized.operation === 'workflow_list')
return packageRoot + '/workflows';
const workflowRoot =
packageRoot + '/workflows/' + encoded(normalized.workflowId);
if (normalized.operation === 'workflow_run_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterAdmittedAtMs === null
? []
: [
['after_admitted_at_ms', normalized.afterAdmittedAtMs],
['after_run_id', normalized.afterRunId!],
];
return (
workflowRoot + '/runs' + query([...cursor, ['limit', normalized.limit]])
);
}
const runRoot = workflowRoot + '/runs/' + encoded(normalized.runId);
if (normalized.operation === 'workflow_run_read') return runRoot;
if (normalized.operation === 'workflow_event_list')
return (
runRoot +
'/events' +
query([
['after_sequence', normalized.afterSequence],
['limit', normalized.limit],
])
);
const cursor: (readonly [string, string | number])[] =
normalized.afterStepKey === null
? []
: [
['after_step_key', normalized.afterStepKey],
['after_step_run_id', normalized.afterStepRunId!],
];
return runRoot + '/steps' + query([...cursor, ['limit', normalized.limit]]);
}
@@ -9,17 +9,14 @@ import {
ClusterCopilotClientConfigurationError,
ClusterCopilotClientRemoteError,
ClusterCopilotClientRequestError,
type ClusterCopilotClientCommand,
type ClusterCopilotClientResult,
} from '../copilot-client/client';
import {
type ClusterCopilotConsoleAssets,
} from './assets';
import { type ClusterCopilotConsoleAssets } from './assets';
import {
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
InvalidClusterCopilotConsoleReadRequestError,
clusterCopilotConsoleClientCommand,
normalizeClusterCopilotConsoleReadRequest,
type ClusterCopilotConsoleReadOperation,
type ClusterCopilotConsoleReadRequest,
} from './contracts';
export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
@@ -31,9 +28,13 @@ export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
});
export interface ClusterCopilotConsoleExecutor {
execute(
command: Readonly<ClusterCopilotClientCommand>,
): Promise<Readonly<ClusterCopilotClientResult>>;
execute(request: Readonly<ClusterCopilotConsoleReadRequest>): Promise<
Readonly<{
schemaVersion: 1;
requestId: string;
result: Readonly<Record<string, unknown>>;
}>
>;
}
export interface ClusterCopilotConsoleServerOptions {
@@ -111,10 +112,7 @@ export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
return invalid();
}
const decoded = Buffer.from(value, 'base64url');
if (
decoded.byteLength !== 32 ||
decoded.toString('base64url') !== value
) {
if (decoded.byteLength !== 32 || decoded.toString('base64url') !== value) {
decoded.fill(0);
return invalid();
}
@@ -125,7 +123,9 @@ export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
.digest();
}
function securityHeaders(contentType: string): Readonly<Record<string, string>> {
function securityHeaders(
contentType: string,
): Readonly<Record<string, string>> {
return Object.freeze({
'cache-control': 'no-store',
'content-security-policy': CONTENT_SECURITY_POLICY,
@@ -181,11 +181,29 @@ function headerCount(request: IncomingMessage, name: string): number {
return count;
}
function targetPath(request: IncomingMessage): 'inspect' | 'output' | null {
const READ_ROUTES: Readonly<
Record<string, ClusterCopilotConsoleReadOperation>
> = Object.freeze({
'/api/v1/copilot/inspect': 'inspect',
'/api/v1/copilot/output': 'output',
'/api/v1/observe/run-list': 'run_list',
'/api/v1/observe/run': 'run_read',
'/api/v1/observe/run-events': 'run_event_list',
'/api/v1/observe/run-steps': 'run_step_list',
'/api/v1/observe/task-list': 'task_list',
'/api/v1/observe/task': 'task_read',
'/api/v1/observe/workflow-list': 'workflow_list',
'/api/v1/observe/workflow-run-list': 'workflow_run_list',
'/api/v1/observe/workflow-run': 'workflow_run_read',
'/api/v1/observe/workflow-events': 'workflow_event_list',
'/api/v1/observe/workflow-steps': 'workflow_step_list',
});
function targetPath(
request: IncomingMessage,
): ClusterCopilotConsoleReadOperation | null {
if (request.method !== 'POST') return null;
if (request.url === '/api/v1/copilot/inspect') return 'inspect';
if (request.url === '/api/v1/copilot/output') return 'output';
return null;
return request.url === undefined ? null : READ_ROUTES[request.url] ?? null;
}
function authorize(
@@ -279,11 +297,7 @@ function remoteFailure(
error: ClusterCopilotClientRemoteError,
): void {
const statusCode =
error.statusCode === 404
? 404
: error.statusCode === 429
? 429
: 502;
error.statusCode === 404 ? 404 : error.statusCode === 429 ? 429 : 502;
sendJson(
response,
statusCode,
@@ -377,9 +391,7 @@ export async function startClusterCopilotConsoleServer(
request.resume();
return;
}
if (
inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests
) {
if (inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests) {
sendJson(
response,
429,
@@ -400,9 +412,7 @@ export async function startClusterCopilotConsoleServer(
if (normalized.operation !== operation) {
throw new InvalidClusterCopilotConsoleReadRequestError();
}
const result = await executor.execute(
clusterCopilotConsoleClientCommand(normalized),
);
const result = await executor.execute(normalized);
const envelope = Object.freeze({
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
operation,
@@ -416,12 +426,7 @@ export async function startClusterCopilotConsoleServer(
) {
throw new ClusterCopilotClientRequestError();
}
send(
response,
200,
'application/json; charset=utf-8',
encoded,
);
send(response, 200, 'application/json; charset=utf-8', encoded);
} catch (error) {
if (response.headersSent) {
response.destroy();
@@ -20,6 +20,7 @@ const {
CLUSTER_COPILOT_CLIENT_CONFIG_SCHEMA,
ClusterCopilotClientRemoteError,
executeClusterCopilotClient,
executeClusterProjectApiRead,
probeClusterCopilotClientReadiness,
validateClusterCopilotClientConfiguration,
} = require('../dist/copilot-client/client.js');
@@ -35,7 +36,9 @@ const {
validateClusterCopilotClientResponse,
} = require('../dist/copilot-client/contracts.js');
const credential = `ql3c_credential-1_${Buffer.alloc(32, 7).toString('base64url')}`;
const credential = `ql3c_credential-1_${Buffer.alloc(32, 7).toString(
'base64url',
)}`;
const baseCommand = {
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
projectId: 'project-1',
@@ -58,11 +61,7 @@ function temporaryDirectory(t) {
}
function configuration(directory, port) {
const caFile = privateFile(
directory,
'ca.pem',
fs.readFileSync(caFixture),
);
const caFile = privateFile(directory, 'ca.pem', fs.readFileSync(caFixture));
return privateFile(
directory,
'client.json',
@@ -113,9 +112,7 @@ async function startServer(handler) {
port: server.address().port,
close: () =>
new Promise((resolvePromise, reject) => {
server.close((error) =>
error ? reject(error) : resolvePromise(),
);
server.close((error) => (error ? reject(error) : resolvePromise()));
}),
};
}
@@ -266,7 +263,11 @@ test('normalizes only the four bounded commands and derives exact requests', ()
});
test('validates exact target-bound response state for every operation', () => {
const diagnose = { ...baseCommand, operation: 'diagnose', traceId: 'trace-1' };
const diagnose = {
...baseCommand,
operation: 'diagnose',
traceId: 'trace-1',
};
const inspect = { ...baseCommand, operation: 'inspect' };
const output = { ...baseCommand, operation: 'output' };
const cancel = {
@@ -307,8 +308,7 @@ test('validates exact target-bound response state for every operation', () => {
'diagnosis',
);
assert.equal(
validateClusterCopilotClientResponse(cancellationResponse(), cancel)
.status,
validateClusterCopilotClientResponse(cancellationResponse(), cancel).status,
'accepted',
);
assert.equal(
@@ -357,10 +357,10 @@ test('validates exact target-bound response state for every operation', () => {
validateClusterCopilotClientResponse(invalidOutput, output),
);
assert.throws(() =>
validateClusterCopilotClientResponse(
cancellationResponse(),
{ ...cancel, requestId: 'other-request' },
),
validateClusterCopilotClientResponse(cancellationResponse(), {
...cancel,
requestId: 'other-request',
}),
);
});
@@ -383,7 +383,10 @@ test('uses TLS 1.3, Bearer credential and exact request identities end to end',
if (request.url === '/readyz') {
assert.equal(request.headers.authorization, undefined);
jsonResponse(response, 200, null, { status: 'ready' });
} else if (request.method === 'POST' && request.url.endsWith('/cancellation')) {
} else if (
request.method === 'POST' &&
request.url.endsWith('/cancellation')
) {
jsonResponse(response, 202, requestId, cancellationResponse());
} else if (request.method === 'POST') {
jsonResponse(response, 201, requestId, diagnoseResponse());
@@ -448,6 +451,65 @@ test('uses TLS 1.3, Bearer credential and exact request identities end to end',
});
});
test('reuses the credential-safe TLS boundary for fixed Project API reads only', async (t) => {
const seen = [];
const server = await startServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
seen.push({
method: request.method,
url: request.url,
authorization: request.headers.authorization,
requestId: request.headers['x-request-id'],
bodyBytes: Buffer.concat(chunks).byteLength,
tls: request.socket.getProtocol(),
});
jsonResponse(response, 200, request.headers['x-request-id'], {
runs: [],
hasMore: false,
});
});
});
t.after(() => server.close());
const directory = temporaryDirectory(t);
const execution = {
configFile: configuration(directory, server.port),
credentialFile: privateFile(directory, 'credential', credential),
path: '/api/v3/projects/project-1/runs?limit=32',
requestId: 'console-read-1',
};
assert.deepEqual(await executeClusterProjectApiRead(execution), {
schemaVersion: 1,
requestId: 'console-read-1',
result: { runs: [], hasMore: false },
});
assert.deepEqual(seen, [
{
method: 'GET',
url: '/api/v3/projects/project-1/runs?limit=32',
authorization: `Bearer ${credential}`,
requestId: 'console-read-1',
bodyBytes: 0,
tls: 'TLSv1.3',
},
]);
await assert.rejects(
executeClusterProjectApiRead({
...execution,
path: '/api/v3/projects/project-1/runs/run-1/cancellation',
}),
{ code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED' },
);
await assert.rejects(
executeClusterProjectApiRead({
...execution,
path: 'https://attacker.example/api/v3/projects/project-1/runs',
}),
{ code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED' },
);
});
test('fails closed on weak files, request-id drift and low-sensitive remote errors', async (t) => {
let mode = 'readiness-drift';
const server = await startServer((request, response) => {
@@ -488,18 +550,15 @@ test('fails closed on weak files, request-id drift and low-sensitive remote erro
});
mode = 'remote';
await assert.rejects(
executeClusterCopilotClient(paths),
(error) => {
assert.equal(error instanceof ClusterCopilotClientRemoteError, true);
assert.equal(error.statusCode, 429);
assert.equal(error.responseCode, 'copilot_rate_limited');
assert.equal(error.requestId, baseCommand.requestId);
assert.equal(error.retryAfterSeconds, 30);
assert.equal(JSON.stringify(error).includes('private detail'), false);
return true;
},
);
await assert.rejects(executeClusterCopilotClient(paths), (error) => {
assert.equal(error instanceof ClusterCopilotClientRemoteError, true);
assert.equal(error.statusCode, 429);
assert.equal(error.responseCode, 'copilot_rate_limited');
assert.equal(error.requestId, baseCommand.requestId);
assert.equal(error.retryAfterSeconds, 30);
assert.equal(JSON.stringify(error).includes('private detail'), false);
return true;
});
const cli = await runCli([
`--config=${configFile}`,
@@ -602,11 +661,7 @@ test('rejects response framing drift, oversized bodies, aborts and timeouts', as
});
t.after(() => server.close());
const directory = temporaryDirectory(t);
const caFile = privateFile(
directory,
'ca.pem',
fs.readFileSync(caFixture),
);
const caFile = privateFile(directory, 'ca.pem', fs.readFileSync(caFixture));
const configFile = privateFile(
directory,
'client.json',
@@ -625,12 +680,7 @@ test('rejects response framing drift, oversized bodies, aborts and timeouts', as
}),
credentialFile: privateFile(directory, 'credential', credential),
};
for (const failureMode of [
'content-type',
'oversized',
'abort',
'timeout',
]) {
for (const failureMode of ['content-type', 'oversized', 'abort', 'timeout']) {
mode = failureMode;
await assert.rejects(executeClusterCopilotClient(paths), {
code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED',
@@ -16,6 +16,7 @@ const {
const {
CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
clusterCopilotConsoleClientCommand,
clusterCopilotConsoleProjectReadPath,
normalizeClusterCopilotConsoleReadRequest,
} = require('../dist/copilot-console/contracts.js');
const {
@@ -42,7 +43,8 @@ function inspection() {
operation: 'inspect',
requestId: 'transport-read-1',
result: {
schema: 'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1',
schema:
'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1',
status: 'terminal',
projectId: 'project-main',
sourceRunId: 'run-source-1',
@@ -72,7 +74,8 @@ function output() {
operation: 'output',
requestId: 'transport-read-2',
result: {
schema: 'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
schema:
'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
status: 'available',
projectId: 'project-main',
sourceRunId: 'run-source-1',
@@ -180,7 +183,7 @@ async function unusedPort() {
return port;
}
test('normalizes only the two read operations into the shared client contract', () => {
test('normalizes Copilot and fixed Project observation operations without arbitrary paths', () => {
assert.deepEqual(
clusterCopilotConsoleClientCommand(
normalizeClusterCopilotConsoleReadRequest(target('inspect')),
@@ -197,6 +200,22 @@ test('normalizes only the two read operations into the shared client contract',
clusterCopilotConsoleClientCommand(target('output')).operation,
'output',
);
const runList = normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'run_list',
projectId: 'project-main',
requestId: 'console-read-1',
afterCreatedAtMs: 1_700_000_000_000,
afterRunId: 'run-9',
limit: 32,
});
assert.equal(
clusterCopilotConsoleProjectReadPath(runList),
'/api/v3/projects/project-main/runs?after_created_at_ms=1700000000000&after_run_id=run-9&limit=32',
);
assert.throws(() => clusterCopilotConsoleClientCommand(runList), {
code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID',
});
assert.throws(
() =>
normalizeClusterCopilotConsoleReadRequest({
@@ -215,22 +234,162 @@ test('normalizes only the two read operations into the shared client contract',
);
});
test('maps every reviewed Project observation operation to one fixed GET path', () => {
const workflowRunId = '123e4567-e89b-42d3-a456-426614174000';
const workflowStepRunId = '123e4567-e89b-42d3-a456-426614174001';
const cases = [
[
{
operation: 'run_list',
afterCreatedAtMs: 1_700_000_000_000,
afterRunId: 'run-9',
limit: 32,
},
'/api/v3/projects/project-main/runs?after_created_at_ms=1700000000000&after_run_id=run-9&limit=32',
],
[
{ operation: 'run_read', runId: 'run-9' },
'/api/v3/projects/project-main/runs/run-9',
],
[
{
operation: 'run_event_list',
runId: 'run-9',
afterSequence: 7,
limit: 16,
},
'/api/v3/projects/project-main/runs/run-9/events?after_sequence=7&limit=16',
],
[
{
operation: 'run_step_list',
runId: 'run-9',
afterStepKey: 'model',
afterStepRunId: 'step-run-3',
limit: 8,
},
'/api/v3/projects/project-main/runs/run-9/steps?after_step_key=model&after_step_run_id=step-run-3&limit=8',
],
[
{ operation: 'task_list', afterTaskId: 'task-9', limit: 4 },
'/api/v3/projects/project-main/tasks?after_task_id=task-9&limit=4',
],
[
{ operation: 'task_read', taskId: 'task-9' },
'/api/v3/projects/project-main/tasks/task-9',
],
[
{ operation: 'workflow_list', packageName: 'ops-pack' },
'/api/v3/projects/project-main/packages/ops-pack/workflows',
],
[
{
operation: 'workflow_run_list',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
afterAdmittedAtMs: 1_700_000_000_000,
afterRunId: workflowRunId,
limit: 32,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs?after_admitted_at_ms=1700000000000&after_run_id=123e4567-e89b-42d3-a456-426614174000&limit=32',
],
[
{
operation: 'workflow_run_read',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: workflowRunId,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000',
],
[
{
operation: 'workflow_event_list',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: workflowRunId,
afterSequence: 9,
limit: 16,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000/events?after_sequence=9&limit=16',
],
[
{
operation: 'workflow_step_list',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: workflowRunId,
afterStepKey: 'publish',
afterStepRunId: workflowStepRunId,
limit: 8,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000/steps?after_step_key=publish&after_step_run_id=123e4567-e89b-42d3-a456-426614174001&limit=8',
],
];
for (const [requestFields, expectedPath] of cases) {
const normalized = normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
projectId: 'project-main',
requestId: 'console-read-1',
...requestFields,
});
assert.equal(
clusterCopilotConsoleProjectReadPath(normalized),
expectedPath,
);
assert.equal(Object.isFrozen(normalized), true);
}
assert.throws(
() =>
normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'run_step_list',
projectId: 'project-main',
requestId: 'console-read-1',
runId: 'run-9',
afterStepKey: 'model',
afterStepRunId: null,
limit: 8,
}),
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
);
assert.throws(
() =>
normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'workflow_run_read',
projectId: 'project-main',
requestId: 'console-read-1',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: 'not-a-workflow-run-uuid',
}),
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
);
});
test('loads only digest-bound packaged assets and rejects drift', async (t) => {
const assets = loadClusterCopilotConsoleAssets(moduleDirectory);
assert.match(assets.html, /故障诊断,不替你执行/);
assert.match(assets.html, /沿着证据读,不替集群做决定/);
assert.match(assets.css, /prefers-reduced-motion/);
assert.match(assets.javascript, /textContent = fact\.result\.text/);
assert.doesNotMatch(assets.javascript, /localStorage|sessionStorage|innerHTML/);
assert.match(assets.javascript, /output\.textContent = JSON\.stringify/);
assert.match(assets.javascript, /run_event_list/);
assert.doesNotMatch(
assets.javascript,
/localStorage|sessionStorage|innerHTML/,
);
const root = await mkdtemp(join(tmpdir(), 'ql3-console-assets-'));
t.after(() => require('node:fs').rmSync(root, { recursive: true, force: true }));
t.after(() =>
require('node:fs').rmSync(root, { recursive: true, force: true }),
);
const fakeModuleDirectory = join(root, 'dist', 'copilot-console');
await mkdir(fakeModuleDirectory, { recursive: true });
await cp(
resolve(moduleDirectory, '../../assets'),
join(root, 'assets'),
{ recursive: true },
);
await cp(resolve(moduleDirectory, '../../assets'), join(root, 'assets'), {
recursive: true,
});
await writeFile(
join(root, 'assets', 'copilot-console', 'app.js'),
'"drift";\n',
@@ -250,13 +409,16 @@ test('serves an immutable same-origin shell with a closed browser policy', async
assert.equal(html.headers['x-frame-options'], 'DENY');
assert.match(html.headers['content-security-policy'], /default-src 'none'/);
assert.match(html.headers['content-security-policy'], /connect-src 'self'/);
assert.match(html.text, /Cluster field console/);
assert.match(html.text, /Cluster field ledger/);
const css = await request(server.origin, { path: '/app.css' });
const javascript = await request(server.origin, { path: '/app.js' });
assert.equal(css.statusCode, 200);
assert.equal(javascript.statusCode, 200);
assert.equal(javascript.headers['content-type'], 'text/javascript; charset=utf-8');
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) => {
@@ -299,7 +461,7 @@ test('keeps the Cluster credential server-side and forwards one exact inspect',
assert.equal(response.statusCode, 200);
assert.deepEqual(commands, [
{
schema: 'qinglong/cluster-copilot-client-command@v1',
schema: 'qinglong/cluster-copilot-console-read-request@v1',
operation: 'inspect',
projectId: 'project-main',
sourceRunId: 'run-source-1',
@@ -314,6 +476,39 @@ test('keeps the Cluster credential server-side and forwards one exact inspect',
assert.doesNotMatch(response.text, /ql3c_|authorization|credential/i);
});
test('forwards one exact bounded Run list read and exposes no path field', async (t) => {
const requests = [];
const { server, headers } = await fixture(async (read) => {
requests.push(read);
return {
schemaVersion: 1,
requestId: read.requestId,
result: { runs: [], hasMore: false },
};
});
t.after(() => server.close());
const body = {
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'run_list',
projectId: 'project-main',
requestId: 'console-read-2',
afterCreatedAtMs: null,
afterRunId: null,
limit: 32,
};
const response = await request(server.origin, {
method: 'POST',
path: '/api/v1/observe/run-list',
headers,
body,
});
assert.equal(response.statusCode, 200);
assert.deepEqual(requests, [body]);
assert.deepEqual(response.body.result.result, { runs: [], hasMore: false });
assert.equal(Object.hasOwn(requests[0], 'path'), false);
assert.equal(Object.hasOwn(requests[0], 'url'), false);
});
test('returns model text as JSON data only after an explicit output read', async (t) => {
const { server, headers } = await fixture(async (command) => {
assert.equal(command.operation, 'output');
@@ -331,7 +526,10 @@ test('returns model text as JSON data only after an explicit output read', async
response.body.result.result.result.text,
'<script>never execute</script>',
);
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(
response.headers['content-type'],
'application/json; charset=utf-8',
);
assert.equal(response.headers['x-content-type-options'], 'nosniff');
});
@@ -344,7 +542,10 @@ test('masks wrong Host, Origin, session and every non-read route', async (t) =>
t.after(() => server.close());
const cases = [
{ ...headers, origin: 'https://attacker.example' },
{ ...headers, authorization: 'QL3-Console ' + randomBytes(32).toString('base64url') },
{
...headers,
authorization: 'QL3-Console ' + randomBytes(32).toString('base64url'),
},
{ ...headers, host: 'attacker.example' },
];
for (const candidate of cases) {
@@ -18,8 +18,22 @@ const tlsFixture = path.resolve(
packageRoot,
'../ql3-cluster-control/test/fixtures/mtls',
);
const credential =
'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url');
const credential = 'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url');
const consoleOperations = [
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
];
function privateFile(directory, name, contents) {
const filePath = path.join(directory, name);
@@ -189,7 +203,10 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
component: 'qinglong3-cluster-copilot-console',
event: 'process_failed',
});
assert.doesNotMatch(failed.stderr, /client-secret|cluster-secret|browser-secret/);
assert.doesNotMatch(
failed.stderr,
/client-secret|cluster-secret|browser-secret/,
);
});
test('preflight proves private authority and unauthenticated TLS 1.3 readiness', async (t) => {
@@ -214,7 +231,7 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
operations: consoleOperations,
mutation: false,
});
assert.deepEqual(value.requests, [
@@ -244,18 +261,19 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
{ cwd: packageRoot, stdio: ['ignore', 'pipe', 'pipe'] },
);
t.after(() => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
if (child.exitCode === null && child.signalCode === null)
child.kill('SIGKILL');
});
const started = JSON.parse(await firstLine(child.stdout));
assert.equal(started.event, 'started');
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.deepEqual(started.operations, ['inspect', 'output']);
assert.deepEqual(started.operations, consoleOperations);
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/);
assert.match(shell.body, /Cluster field ledger/);
child.kill('SIGTERM');
const result = await new Promise((resolve, reject) => {
child.once('error', reject);
@@ -254,13 +254,13 @@ child.stdout.on('data', (chunk) => {
if (newline === -1 || settled) return;
let started;
try { started = JSON.parse(stdout.slice(0, newline)); } catch { finish(43); return; }
if (started.event !== 'started' || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(started.origin) || JSON.stringify(started.operations) !== JSON.stringify(['inspect', 'output']) || started.mutation !== false) { finish(44); return; }
if (started.event !== 'started' || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(started.origin) || JSON.stringify(started.operations) !== JSON.stringify(['inspect', 'output', 'run_list', 'run_read', 'run_event_list', 'run_step_list', 'task_list', 'task_read', 'workflow_list', 'workflow_run_list', 'workflow_run_read', 'workflow_event_list', 'workflow_step_list']) || started.mutation !== false) { finish(44); return; }
get(started.origin, (response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.once('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (response.statusCode !== 200 || !body.includes('Cluster field console') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; }
if (response.statusCode !== 200 || !body.includes('Cluster field ledger') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; }
child.once('close', (status, signal) => {
if (status !== 0 || signal !== null) { finish(46); return; }
settled = true;
@@ -411,7 +411,9 @@ process.once('SIGINT', () => child.kill('SIGINT'));
} catch {}
}
fail(
`published Console did not start (running=${String(state?.Running)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
`published Console did not start (running=${String(
state?.Running,
)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
);
}
let started;
@@ -433,16 +435,14 @@ process.once('SIGINT', () => child.kill('SIGINT'));
container,
`${containerPort}/tcp`,
]).trim();
const publishedMatch = /^127\.0\.0\.1:([1-9][0-9]{0,4})$/u.exec(
published,
);
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));",
"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 ledger'))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 },
+51 -18
View File
@@ -4,6 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
const CONSOLE_ROOT = 'packages/ql3-cluster-admin/src/copilot-console';
const CLIENT_FILE = 'packages/ql3-cluster-admin/src/copilot-client/client.ts';
const ASSET_ROOT = 'packages/ql3-cluster-admin/assets/copilot-console';
const DEPLOYMENT_ROOT = 'deploy/console/ql3-cluster-copilot';
const REQUIRED_FILES = Object.freeze([
@@ -11,6 +12,7 @@ const REQUIRED_FILES = Object.freeze([
CONSOLE_ROOT + '/cli.ts',
CONSOLE_ROOT + '/contracts.ts',
CONSOLE_ROOT + '/server.ts',
CLIENT_FILE,
ASSET_ROOT + '/index.html',
ASSET_ROOT + '/app.css',
ASSET_ROOT + '/app.js',
@@ -93,18 +95,30 @@ function auditClusterCopilotConsole(options = {}) {
};
expectFragments(CONSOLE_ROOT + '/contracts.ts', [
"export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output'",
'CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS',
'CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA',
'clusterCopilotConsoleClientCommand',
'clusterCopilotConsoleProjectReadPath',
"'run_event_list'",
"'task_read'",
"'workflow_step_list'",
]);
rejectFragments(CONSOLE_ROOT + '/contracts.ts', [
"| 'diagnose'",
"| 'cancel'",
"'cancel'",
'mutationId',
'traceId',
'endpoint',
'credential',
]);
expectFragments(CLIENT_FILE, [
'executeClusterProjectApiRead',
'PROJECT_READ_PATHS',
"method: 'GET'",
'readCredentialBytes(record.credentialFile as string)',
'credentialBytes?.fill(0)',
]);
rejectFragments(CLIENT_FILE, ["method: 'DELETE'", "method: 'PATCH'"]);
expectFragments(CONSOLE_ROOT + '/server.ts', [
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
"networkBoundary === 'container-published-loopback'",
@@ -112,8 +126,10 @@ function auditClusterCopilotConsole(options = {}) {
'request.headers.origin !== expectedOrigin',
"request.headers.host !== expectedOrigin.slice('http://'.length)",
'maximumConcurrentRequests: 2',
"request.url === '/api/v1/copilot/inspect'",
"request.url === '/api/v1/copilot/output'",
"'/api/v1/copilot/inspect': 'inspect'",
"'/api/v1/observe/run-list': 'run_list'",
"'/api/v1/observe/task-list': 'task_list'",
"'/api/v1/observe/workflow-list': 'workflow_list'",
"default-src 'none'",
"frame-ancestors 'none'",
"'cache-control': 'no-store'",
@@ -134,9 +150,9 @@ function auditClusterCopilotConsole(options = {}) {
"'private'",
'validateClusterCopilotClientCredentialFile',
"clusterCredential: 'server_only'",
"networkBoundary: parsed.networkBoundary",
'networkBoundary: parsed.networkBoundary',
"publishedHostAddress: '127.0.0.1'",
"operations: ['inspect', 'output']",
'operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS',
'mutation: false',
]);
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
@@ -146,16 +162,19 @@ function auditClusterCopilotConsole(options = {}) {
'cancel',
]);
expectFragments(ASSET_ROOT + '/index.html', [
'故障诊断,不替你执行。',
'只读边界',
'沿着证据读,不替集群做决定。',
'本机只读 BFF',
'读取 Run 列表',
'读取 Workflow Runs',
'显式读取诊断内容',
'不可信模型输出',
'模型文本是不可信内容',
]);
expectFragments(ASSET_ROOT + '/app.js', [
'credentials: "omit"',
'cache: "no-store"',
'outputText.textContent = fact.result.text',
'sessionToken = ""',
"credentials: 'omit'",
"cache: 'no-store'",
'output.textContent = JSON.stringify(fact, null, 2)',
'nextPage(operation, request, fact)',
"sessionToken = ''",
]);
rejectFragments(ASSET_ROOT + '/app.js', [
'localStorage',
@@ -178,7 +197,8 @@ function auditClusterCopilotConsole(options = {}) {
expectFragments(DEPLOYMENT_ROOT + '/README.md', [
'operator-workstation process',
'Do not deploy it as a Kubernetes workload',
'only `inspect` and explicit `output` reads',
'Run, Task, Workflow',
'thirteen exact operations',
'--port=0',
'TLS 1.3 `GET /readyz`',
'excluded from small router Edge/Standalone artifacts',
@@ -196,7 +216,7 @@ function auditClusterCopilotConsole(options = {}) {
'function runConsoleContract(image)',
"[facade, 'copilot-console'",
"started.event !== 'started'",
"body.includes('Cluster field console')",
"body.includes('Cluster field ledger')",
'runConsoleContract(image);',
'function runPublishedConsoleContract(image)',
'runPublishedConsoleContract(image);',
@@ -219,8 +239,7 @@ function auditClusterCopilotConsole(options = {}) {
);
}
if (
manifest?.bin?.['ql3-copilot-console'] !==
'dist/copilot-console/cli.js' ||
manifest?.bin?.['ql3-copilot-console'] !== 'dist/copilot-console/cli.js' ||
manifest?.exports?.['./copilot-console']?.require !==
'./dist/copilot-console/server.js' ||
!Array.isArray(manifest?.files) ||
@@ -305,7 +324,21 @@ function auditClusterCopilotConsole(options = {}) {
component: 'cluster-copilot-console',
owner: '@qinglong/cluster-admin',
lifecycle: 'operator-workstation-loopback',
operations: Object.freeze(['inspect', 'output']),
operations: Object.freeze([
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
]),
legacyUiCoupled: false,
kubernetesResident: false,
assetCount: 3,
@@ -53,7 +53,7 @@ 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'/);
assert.match(source, /body\.includes\('Cluster field console'\)/);
assert.match(source, /body\.includes\('Cluster field ledger'\)/);
assert.match(source, /runConsoleContract\(image\);/);
assert.match(source, /consoleLoopback: true/);
assert.match(source, /consoleAssets: true/);
@@ -25,7 +25,21 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
component: 'cluster-copilot-console',
owner: '@qinglong/cluster-admin',
lifecycle: 'operator-workstation-loopback',
operations: ['inspect', 'output'],
operations: [
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
],
legacyUiCoupled: false,
kubernetesResident: false,
assetCount: 3,
@@ -47,11 +61,7 @@ test('rejects a remote listener or mutation vocabulary', () => {
root,
readFile: intercept(
'packages/ql3-cluster-admin/src/copilot-console/contracts.ts',
(source) =>
source.replace(
"'inspect' | 'output'",
"'inspect' | 'output' | 'cancel'",
),
(source) => source.replace("'output',", "'output', 'cancel',"),
),
});
assert.equal(listener.compatible, false);
@@ -127,8 +137,7 @@ test('rejects coupling into the legacy UI or Kubernetes workloads', () => {
(source) => source + '\n// ql3-copilot-console\n',
),
});
const kubernetesTarget =
'deploy/kubernetes/ql3-cluster/base/deployment.yaml';
const kubernetesTarget = 'deploy/kubernetes/ql3-cluster/base/deployment.yaml';
const kubernetes = auditClusterCopilotConsole({
root,
readFile: intercept(