From 7955d55629da933734c58fb35772d5e7b37178c2 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 06:09:54 +0800 Subject: [PATCH] feat(ql3): add explicit cluster observation console --- deploy/console/ql3-cluster-copilot/README.md | 23 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...r-run-task-workflow-observation-console.md | 48 ++ docs/adr/README.md | 1 + packages/ql3-cluster-admin/README.md | 18 +- .../assets/copilot-console/app.css | 577 ++++++++---------- .../assets/copilot-console/app.js | 413 ++++++++----- .../assets/copilot-console/index.html | 250 +++----- .../src/copilot-client/client.ts | 303 ++++++++- .../src/copilot-console/assets.ts | 13 +- .../src/copilot-console/cli.ts | 36 +- .../src/copilot-console/contracts.ts | 493 ++++++++++++++- .../src/copilot-console/server.ts | 75 +-- .../test/copilotClient.test.cjs | 130 ++-- .../test/copilotConsole.test.cjs | 235 ++++++- .../test/copilotConsoleCli.test.cjs | 32 +- ...l3-cluster-admin-product-live-contract.cjs | 14 +- scripts/ql3-cluster-copilot-console-audit.cjs | 69 ++- ...l3ClusterAdminProductLiveContract.test.cjs | 2 +- .../ql3ClusterCopilotConsoleAudit.test.cjs | 25 +- 20 files changed, 1881 insertions(+), 877 deletions(-) create mode 100644 docs/adr/ADR-0421-explicit-cluster-run-task-workflow-observation-console.md diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index 9f041e46..c2507e24 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 182a0fa9..7004cb9c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -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 个显式只读 operation;browser 仍不能提交 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/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 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 unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 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::/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stop,compact 为 192 MiB/0.25 CPU/32 PIDs,standard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30;release/OCI/SBOM 定向门 75/75,package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0,backend 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 modules,application+AI 仍为 4,493,043/4,493,175 bytes,MCP 仍为 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 响应式布局和键盘路径;含 ` - +
-
+
-

QingLong 3.0 / Cluster field console

-

故障诊断,不替你执行。

+

QingLong 3.0 / Cluster field ledger

+

沿着证据读,不替集群做决定。

- 只读边界 - inspect · output + 本机只读 BFF + Run · Task · Workflow · Copilot
-
+
+
-

Durable evidence

-

诊断轨迹

+

Explicit read ledger

+

本页证据账本

- 等待目标 + 等待读取
-
- -

先读取状态,再决定是否查看内容

-

- 状态响应只包含有界、低敏的 durable facts。模型文本必须由你再次明确选择。 -

+ +

一次读取,一条可复核事实

+

先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。

- - - +
- Loopback only · no legacy session · no browser credential - QingLong 3.0 incubation / D-327 + Loopback only · explicit reads · zero polling + QingLong 3.0 incubation / D-329
diff --git a/packages/ql3-cluster-admin/src/copilot-client/client.ts b/packages/ql3-cluster-admin/src/copilot-client/client.ts index 2a90453e..67bd4823 100644 --- a/packages/ql3-cluster-admin/src/copilot-client/client.ts +++ b/packages/ql3-cluster-admin/src/copilot-client/client.ts @@ -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>; +} + 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> { 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> { 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> { + 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), + }), + }); + } + 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(); + } +} diff --git a/packages/ql3-cluster-admin/src/copilot-console/assets.ts b/packages/ql3-cluster-admin/src/copilot-console/assets.ts index bc70cc51..33127647 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/assets.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/assets.ts @@ -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); diff --git a/packages/ql3-cluster-admin/src/copilot-console/cli.ts b/packages/ql3-cluster-admin/src/copilot-console/cli.ts index c013508e..93930707 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/cli.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/cli.ts @@ -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 { 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 { const server = await startClusterCopilotConsoleServer({ assets, executor: Object.freeze({ - execute(command: Readonly) { - return executeClusterCopilotCommand({ + execute(request: Readonly) { + 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 { publishedHostAddress: '127.0.0.1', browserCredential: 'forbidden', clusterCredential: 'server_only', - operations: ['inspect', 'output'], + operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, mutation: false, }) + '\n', ); diff --git a/packages/ql3-cluster-admin/src/copilot-console/contracts.ts b/packages/ql3-cluster-admin/src/copilot-console/contracts.ts index 79260aec..d3a9175a 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/contracts.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/contracts.ts @@ -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 { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return invalid(); - } - const record = value as Record; - const keys = Object.keys(record).sort(); +function exact( + record: Record, + 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) { + 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, + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + const record = value as Record; + 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, ): Readonly { 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, +): 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]]); +} diff --git a/packages/ql3-cluster-admin/src/copilot-console/server.ts b/packages/ql3-cluster-admin/src/copilot-console/server.ts index be97ad16..26932d12 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/server.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/server.ts @@ -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, - ): Promise>; + execute(request: Readonly): Promise< + Readonly<{ + schemaVersion: 1; + requestId: string; + result: Readonly>; + }> + >; } 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> { +function securityHeaders( + contentType: string, +): Readonly> { 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 +> = 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(); diff --git a/packages/ql3-cluster-admin/test/copilotClient.test.cjs b/packages/ql3-cluster-admin/test/copilotClient.test.cjs index 13677aa3..d5ed440e 100644 --- a/packages/ql3-cluster-admin/test/copilotClient.test.cjs +++ b/packages/ql3-cluster-admin/test/copilotClient.test.cjs @@ -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', diff --git a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs index 41aa44dc..f7e768f5 100644 --- a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs +++ b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs @@ -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, '', ); - 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) { diff --git a/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs b/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs index fb8badf1..4514ea6a 100644 --- a/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs +++ b/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs @@ -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); diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index 9cb0ea6c..9ef62ed6 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -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 }, diff --git a/scripts/ql3-cluster-copilot-console-audit.cjs b/scripts/ql3-cluster-copilot-console-audit.cjs index f264652a..b2ae93fd 100644 --- a/scripts/ql3-cluster-copilot-console-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-audit.cjs @@ -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, diff --git a/test/back/ql3ClusterAdminProductLiveContract.test.cjs b/test/back/ql3ClusterAdminProductLiveContract.test.cjs index 1bb8e1c9..f65b35a9 100644 --- a/test/back/ql3ClusterAdminProductLiveContract.test.cjs +++ b/test/back/ql3ClusterAdminProductLiveContract.test.cjs @@ -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/); diff --git a/test/back/ql3ClusterCopilotConsoleAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs index 415bbf53..5db1ef1d 100644 --- a/test/back/ql3ClusterCopilotConsoleAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs @@ -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(