From c4a1238a92d1094e23d8272093188e79ffed7bb1 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 04:35:28 +0800 Subject: [PATCH] feat(ql3): add read-only cluster copilot console --- deploy/console/ql3-cluster-copilot/README.md | 74 +++ .../client-config.example.json | 7 + .../containers/ql3-cluster-admin/Dockerfile | 4 +- .../containers/ql3-cluster-admin/package.json | 2 +- .../runtime-dependencies/package.json | 2 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...pback-read-only-cluster-copilot-console.md | 41 ++ docs/adr/README.md | 1 + docs/ql3-package-boundaries.json | 9 +- package.json | 1 + packages/ql3-cluster-admin/README.md | 23 +- .../assets/copilot-console/app.css | 588 ++++++++++++++++++ .../assets/copilot-console/app.js | 190 ++++++ .../assets/copilot-console/index.html | 218 +++++++ packages/ql3-cluster-admin/package.json | 11 +- .../src/copilot-console/assets.ts | 141 +++++ .../src/copilot-console/cli.ts | 245 ++++++++ .../src/copilot-console/contracts.ts | 86 +++ .../src/copilot-console/server.ts | 498 +++++++++++++++ .../src/product-cli/productCommand.ts | 8 +- .../test/copilotConsole.test.cjs | 412 ++++++++++++ .../test/copilotConsoleCli.test.cjs | 261 ++++++++ .../test/productCli.test.cjs | 6 +- ...l3-cluster-admin-product-live-contract.cjs | 98 +++ scripts/ql3-cluster-copilot-console-audit.cjs | 319 ++++++++++ scripts/ql3-cluster-dependency-audit.cjs | 3 + scripts/ql3-cluster-oci-layout-audit.cjs | 2 +- ...l3ClusterAdminProductLiveContract.test.cjs | 11 + .../ql3ClusterCopilotConsoleAudit.test.cjs | 155 +++++ test/back/ql3ClusterDependencyAudit.test.cjs | 4 + test/back/ql3ClusterOciLayoutAudit.test.cjs | 2 +- test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 32 files changed, 3407 insertions(+), 20 deletions(-) create mode 100644 deploy/console/ql3-cluster-copilot/README.md create mode 100644 deploy/console/ql3-cluster-copilot/client-config.example.json create mode 100644 docs/adr/ADR-0419-loopback-read-only-cluster-copilot-console.md create mode 100644 packages/ql3-cluster-admin/assets/copilot-console/app.css create mode 100644 packages/ql3-cluster-admin/assets/copilot-console/app.js create mode 100644 packages/ql3-cluster-admin/assets/copilot-console/index.html create mode 100644 packages/ql3-cluster-admin/src/copilot-console/assets.ts create mode 100644 packages/ql3-cluster-admin/src/copilot-console/cli.ts create mode 100644 packages/ql3-cluster-admin/src/copilot-console/contracts.ts create mode 100644 packages/ql3-cluster-admin/src/copilot-console/server.ts create mode 100644 packages/ql3-cluster-admin/test/copilotConsole.test.cjs create mode 100644 packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs create mode 100644 scripts/ql3-cluster-copilot-console-audit.cjs create mode 100644 test/back/ql3ClusterCopilotConsoleAudit.test.cjs diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md new file mode 100644 index 00000000..89f9441f --- /dev/null +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -0,0 +1,74 @@ +# Cluster Copilot read-only Console + +This Console is an operator-workstation process, not a resident QingLong +service. It serves digest-bound assets on an ephemeral `127.0.0.1` port and +forwards only `inspect` and explicit `output` reads to the existing Cluster +Copilot API. Do not deploy it as a Kubernetes workload, Ingress, shared LAN +listener, Edge component or legacy 2.x Web route. + +Use `ql3-cluster-admin` from the same independently verified Admin release as +the Cluster deployment. The Console intentionally runs directly on the trusted +operator workstation. A container port mapping is not a supported substitute: +the process binds container loopback and must not be widened to `0.0.0.0`. + +## Prepare private authority + +Create an absolute canonical directory owned by the current operator with mode +`0700`. Copy `client-config.example.json` to `client.json`, install the reviewed +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. + +Create an independent 256-bit browser session key without placing its value in +argv or an environment variable: + +```sh +install -d -m 0700 /absolute/private/ql3-copilot-console +umask 077 +node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64url"))' > /absolute/private/ql3-copilot-console/session +chmod 0600 /absolute/private/ql3-copilot-console/client.json /absolute/private/ql3-copilot-console/ca.pem /absolute/private/ql3-copilot-console/credential /absolute/private/ql3-copilot-console/session +``` + +Every file must be a current-owner, non-symlink, canonical regular file. The +session file contains exactly 43 base64url characters and no newline. It is a +browser-to-loopback secret only; it cannot authenticate to the Cluster API. +The `ql3c_` credential remains in the BFF process and is reread for every +upstream request so file rotation takes effect without browser disclosure. + +## Check and start + +Run the preflight first: + +```sh +ql3-cluster-admin copilot-console --check \ + --config /absolute/private/ql3-copilot-console/client.json \ + --credential /absolute/private/ql3-copilot-console/credential \ + --session /absolute/private/ql3-copilot-console/session +``` + +It validates all three private authorities and performs one unauthenticated +TLS 1.3 `GET /readyz`. It does not open the Console listener or reveal paths, +endpoint, credential, Project or Cluster identity. + +Start a session with an ephemeral port: + +```sh +ql3-cluster-admin copilot-console \ + --config /absolute/private/ql3-copilot-console/client.json \ + --credential /absolute/private/ql3-copilot-console/credential \ + --session /absolute/private/ql3-copilot-console/session \ + --port=0 +``` + +Open only the exact `http://127.0.0.1:` origin printed by the process, +then enter the session key from the private file. The browser keeps it only in +page memory; reloading locks the page. Stop the process with `SIGINT` or +`SIGTERM`, then remove or rotate the session file. + +The BFF accepts at most two concurrent reads and sixteen connections, rejects +a third request without queueing, caps request bodies at 4 KiB and responses at +approximately 2 MiB, disables cache/cookies/frames/workers, and never polls. +Model text is rendered as plain text and remains untrusted advice. These limits +keep the workstation surface bounded, but this Cluster-only product is still +excluded from small router Edge/Standalone artifacts. diff --git a/deploy/console/ql3-cluster-copilot/client-config.example.json b/deploy/console/ql3-cluster-copilot/client-config.example.json new file mode 100644 index 00000000..7c5d659e --- /dev/null +++ b/deploy/console/ql3-cluster-copilot/client-config.example.json @@ -0,0 +1,7 @@ +{ + "schema": "qinglong/cluster-copilot-client-config@v1", + "endpoint": "https://replace-cluster-api.example.com:5800/", + "servername": "replace-cluster-api.example.com", + "caFile": "/absolute/private/ql3-copilot-console/ca.pem", + "requestTimeoutMs": 30000 +} diff --git a/deploy/containers/ql3-cluster-admin/Dockerfile b/deploy/containers/ql3-cluster-admin/Dockerfile index cc4ea11c..ef761e29 100644 --- a/deploy/containers/ql3-cluster-admin/Dockerfile +++ b/deploy/containers/ql3-cluster-admin/Dockerfile @@ -74,13 +74,15 @@ COPY --from=workspace /workspace/packages/ql3-cluster-admin/package.json \ node_modules/@qinglong/cluster-admin/package.json COPY --from=workspace /workspace/packages/ql3-cluster-admin/dist \ node_modules/@qinglong/cluster-admin/dist +COPY --from=workspace /workspace/packages/ql3-cluster-admin/assets/copilot-console \ + node_modules/@qinglong/cluster-admin/assets/copilot-console FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS runtime ARG SOURCE_REVISION=uncommitted LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \ - org.opencontainers.image.description="QingLong 3.0 cluster operations and bounded stdio MCP" \ + org.opencontainers.image.description="QingLong 3.0 cluster operations and bounded Copilot surfaces" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.licenses="Apache-2.0" diff --git a/deploy/containers/ql3-cluster-admin/package.json b/deploy/containers/ql3-cluster-admin/package.json index 8d823e91..584793f0 100644 --- a/deploy/containers/ql3-cluster-admin/package.json +++ b/deploy/containers/ql3-cluster-admin/package.json @@ -2,7 +2,7 @@ "name": "@qinglong/cluster-admin-image-dependencies", "version": "3.0.0-alpha.0", "private": true, - "description": "Locked external dependencies for QingLong 3.0 cluster operations and bounded stdio MCP", + "description": "Locked external dependencies for QingLong 3.0 cluster operations and bounded Copilot surfaces", "license": "Apache-2.0", "engines": { "node": ">=24.18.0 <25" diff --git a/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json b/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json index 27d70c84..3919a282 100644 --- a/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json +++ b/deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json @@ -2,7 +2,7 @@ "name": "@qinglong/cluster-admin-image-dependencies", "version": "3.0.0-alpha.0", "private": true, - "description": "Production-only external dependency root for QingLong 3.0 cluster operations and bounded stdio MCP", + "description": "Production-only external dependency root for QingLong 3.0 cluster operations and bounded Copilot surfaces", "license": "Apache-2.0", "engines": { "node": ">=24.18.0 <25" diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index d7b47f56..6e715bad 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-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

+

故障诊断,不替你执行。

+
+
+
+ + 只读边界 + inspect · output +
+
+ +
+
+
+

Target coordinates

+

定位一次诊断

+

+ 输入已存在的 Project、源 Run 和诊断请求。页面不会创建、取消或重试任何任务。 +

+
+ +
+ +
+ + +
+

+ 从独立的 0600 session 文件读取并粘贴;仅保留在当前页面内存,不会发送到 Cluster API。 +

+
+ + + + +
+ +
+
+
+

Durable evidence

+

诊断轨迹

+
+ 等待目标 +
+ +
+ +

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

+

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

+
+ + + +
+
+
+ +
+ Loopback only · no legacy session · no browser credential + QingLong 3.0 incubation / D-327 +
+
+ + diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index df2af9ef..0cf789bc 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -2,7 +2,7 @@ "name": "@qinglong/cluster-admin", "version": "3.0.0-alpha.0", "private": true, - "description": "QingLong 3.0 cluster operations and bounded Copilot MCP surface", + "description": "QingLong 3.0 cluster operations and bounded Copilot MCP/Console surfaces", "license": "Apache-2.0", "engines": { "node": ">=24.18.0 <25" @@ -384,11 +384,17 @@ "types": "./dist/copilot-mcp/server.d.ts", "require": "./dist/copilot-mcp/server.js", "default": "./dist/copilot-mcp/server.js" + }, + "./copilot-console": { + "types": "./dist/copilot-console/server.d.ts", + "require": "./dist/copilot-console/server.js", + "default": "./dist/copilot-console/server.js" } }, "files": [ "dist/**/*.js", - "dist/**/*.d.ts" + "dist/**/*.d.ts", + "assets/copilot-console/*" ], "scripts": { "build": "tsc -p tsconfig.json", @@ -400,6 +406,7 @@ "ql3-cluster-admin": "dist/product-cli/cli.js", "ql3-copilot-client": "dist/copilot-client/cli.js", "ql3-copilot-mcp": "dist/copilot-mcp/cli.js", + "ql3-copilot-console": "dist/copilot-console/cli.js", "ql3-plugin-package-recover": "dist/plugin-package/recovery/pluginPackageRecoveryCli.js", "ql3-plugin-package-manage": "dist/plugin-package/management/pluginPackageManagementCli.js", "ql3-plugin-package-client": "dist/plugin-package/management/pluginPackageManagementClientCli.js", diff --git a/packages/ql3-cluster-admin/src/copilot-console/assets.ts b/packages/ql3-cluster-admin/src/copilot-console/assets.ts new file mode 100644 index 00000000..bc70cc51 --- /dev/null +++ b/packages/ql3-cluster-admin/src/copilot-console/assets.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto'; +import { + lstatSync, + readFileSync, + realpathSync, + type PathLike, +} from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { TextDecoder } from 'node:util'; + +export interface ClusterCopilotConsoleAssets { + readonly html: string; + readonly css: string; + readonly javascript: string; +} + +export class ClusterCopilotConsoleAssetError extends Error { + readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_ASSET_INVALID'; + + constructor() { + super('Cluster Copilot Console asset is invalid'); + this.name = 'ClusterCopilotConsoleAssetError'; + } +} + +const ASSETS = Object.freeze([ + Object.freeze({ + name: 'index.html', + field: 'html', + maximumBytes: 32 * 1024, + digest: 'f9fa959f30b92c6b000eecb744ce1d0a7fce822c62b3e17dcf10d4d579a072ac', + }), + Object.freeze({ + name: 'app.css', + field: 'css', + maximumBytes: 64 * 1024, + digest: '200c3405e1e12329fcfb50509b31b19f1567a91552865f039ce0c2de1530032c', + }), + Object.freeze({ + name: 'app.js', + field: 'javascript', + maximumBytes: 32 * 1024, + digest: 'd60913e725e767d9fa2cb65d60c0eae6d75d219f4bec8aad166bed8b6507fe02', + }), +] as const); + +function invalid(): never { + throw new ClusterCopilotConsoleAssetError(); +} + +function inside(parent: string, candidate: string): boolean { + const pathFromParent = relative(parent, candidate); + return ( + pathFromParent !== '' && + pathFromParent !== '..' && + !pathFromParent.startsWith('..' + sep) && + !isAbsolute(pathFromParent) + ); +} + +function readAsset( + assetRoot: string, + name: string, + maximumBytes: number, + digest: string, +): string { + const candidate = resolve(assetRoot, name); + const status = lstatSync(candidate, { throwIfNoEntry: false }); + if ( + status === undefined || + !status.isFile() || + status.isSymbolicLink() || + status.size < 1 || + status.size > maximumBytes + ) { + return invalid(); + } + const canonicalRoot = realpathSync(assetRoot); + const canonicalCandidate = realpathSync(candidate); + if ( + !inside(canonicalRoot, canonicalCandidate) || + canonicalCandidate !== resolve(canonicalRoot, name) + ) { + return invalid(); + } + let bytes: Buffer | undefined; + try { + bytes = readFileSync(candidate as PathLike); + if ( + bytes.byteLength !== status.size || + createHash('sha256').update(bytes).digest('hex') !== digest || + bytes.includes(0) + ) { + return invalid(); + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + if (error instanceof ClusterCopilotConsoleAssetError) throw error; + return invalid(); + } finally { + bytes?.fill(0); + } +} + +export function loadClusterCopilotConsoleAssets( + moduleDirectory: string, +): Readonly { + if (typeof moduleDirectory !== 'string' || !isAbsolute(moduleDirectory)) { + return invalid(); + } + const packageRoot = resolve(moduleDirectory, '..', '..'); + const assetRoot = resolve(packageRoot, 'assets', 'copilot-console'); + const packageStatus = lstatSync(packageRoot, { throwIfNoEntry: false }); + const assetStatus = lstatSync(assetRoot, { throwIfNoEntry: false }); + if ( + packageStatus === undefined || + !packageStatus.isDirectory() || + packageStatus.isSymbolicLink() || + assetStatus === undefined || + !assetStatus.isDirectory() || + assetStatus.isSymbolicLink() || + realpathSync(assetRoot) !== + resolve(realpathSync(packageRoot), 'assets', 'copilot-console') + ) { + return invalid(); + } + const result: Record = {}; + for (const asset of ASSETS) { + result[asset.field] = readAsset( + assetRoot, + asset.name, + asset.maximumBytes, + asset.digest, + ); + } + return Object.freeze({ + html: result.html!, + css: result.css!, + javascript: result.javascript!, + }); +} diff --git a/packages/ql3-cluster-admin/src/copilot-console/cli.ts b/packages/ql3-cluster-admin/src/copilot-console/cli.ts new file mode 100644 index 00000000..eab96225 --- /dev/null +++ b/packages/ql3-cluster-admin/src/copilot-console/cli.ts @@ -0,0 +1,245 @@ +#!/usr/bin/env node + +import { + executeClusterCopilotCommand, + probeClusterCopilotClientReadiness, + validateClusterCopilotClientConfiguration, + validateClusterCopilotClientCredentialFile, + type ClusterCopilotClientCommand, +} from '../copilot-client/client'; +import { readCanonicalFile } from '../management-support/managementClientConfiguration'; +import { loadClusterCopilotConsoleAssets } from './assets'; +import { + clusterCopilotConsoleSessionDigest, + startClusterCopilotConsoleServer, +} from './server'; + +const USAGE = [ + 'Usage:', + ' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]', + ' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session', + '', + 'The Console binds only 127.0.0.1 and exposes inspect/output reads.', + 'The browser session key remains in a separate owner-private 0600 file.', +].join('\n'); + +interface ClusterCopilotConsoleCliArguments { + readonly check: boolean; + readonly configFile: string; + readonly credentialFile: string; + readonly sessionFile: string; + readonly port: number; +} + +const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/; +const MAXIMUM_SESSION_BYTES = 128; + +function usageFailure(): never { + process.stderr.write(USAGE + '\n'); + process.exit(64); +} + +function argumentValue( + argv: readonly string[], + index: number, + name: string, +): Readonly<{ value: string; consumed: number }> | null { + const current = argv[index]; + if (current === name) { + const next = argv[index + 1]; + if (typeof next !== 'string' || next === '' || next.startsWith('--')) { + return usageFailure(); + } + return Object.freeze({ value: next, consumed: 2 }); + } + const prefix = name + '='; + if (current?.startsWith(prefix) && current.length > prefix.length) { + return Object.freeze({ + value: current.slice(prefix.length), + consumed: 1, + }); + } + return null; +} + +export function parseClusterCopilotConsoleCliArguments( + argv: readonly string[], +): Readonly { + let check = false; + let configFile: string | undefined; + let credentialFile: string | undefined; + let sessionFile: string | undefined; + let port = 0; + let portSeen = false; + for (let index = 0; index < argv.length; ) { + const current = argv[index]; + if (current === '--check' && !check) { + check = true; + index += 1; + continue; + } + const config = argumentValue(argv, index, '--config'); + if (config) { + if (configFile !== undefined) return usageFailure(); + configFile = config.value; + index += config.consumed; + continue; + } + const credential = argumentValue(argv, index, '--credential'); + if (credential) { + if (credentialFile !== undefined) return usageFailure(); + credentialFile = credential.value; + index += credential.consumed; + continue; + } + const session = argumentValue(argv, index, '--session'); + if (session) { + if (sessionFile !== undefined) return usageFailure(); + sessionFile = session.value; + index += session.consumed; + continue; + } + const portArgument = argumentValue(argv, index, '--port'); + if (portArgument) { + if (portSeen || !/^(?:0|[1-9][0-9]{0,4})$/.test(portArgument.value)) { + return usageFailure(); + } + portSeen = true; + port = Number(portArgument.value); + if ( + !Number.isSafeInteger(port) || + (port !== 0 && (port < 1_024 || port > 65_535)) + ) { + return usageFailure(); + } + index += portArgument.consumed; + continue; + } + return usageFailure(); + } + if ( + configFile === undefined || + credentialFile === undefined || + sessionFile === undefined || + (check && port !== 0) + ) { + return usageFailure(); + } + return Object.freeze({ + check, + configFile, + credentialFile, + sessionFile, + port, + }); +} + +function readSessionDigest(sessionFile: string): Buffer { + let bytes: Buffer | undefined; + try { + bytes = readCanonicalFile( + sessionFile, + MAXIMUM_SESSION_BYTES, + 'private', + ); + if ( + bytes.some((byte) => byte > 0x7f) || + !SESSION_TOKEN.test(bytes.toString('ascii')) + ) { + throw new Error('invalid session token'); + } + return clusterCopilotConsoleSessionDigest(bytes.toString('ascii')); + } finally { + bytes?.fill(0); + } +} + +async function main(): Promise { + if ( + process.argv.length === 3 && + (process.argv[2] === '--help' || process.argv[2] === '-h') + ) { + process.stdout.write(USAGE + '\n'); + return; + } + const parsed = parseClusterCopilotConsoleCliArguments(process.argv.slice(2)); + const assets = loadClusterCopilotConsoleAssets(__dirname); + validateClusterCopilotClientConfiguration(parsed.configFile); + validateClusterCopilotClientCredentialFile(parsed.credentialFile); + const sessionDigest = readSessionDigest(parsed.sessionFile); + if (parsed.check) { + try { + const readiness = await probeClusterCopilotClientReadiness( + parsed.configFile, + ); + process.stdout.write( + JSON.stringify({ + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-console', + event: 'preflight_checked', + ready: readiness.ready, + listenAddress: '127.0.0.1', + browserCredential: 'forbidden', + clusterCredential: 'server_only', + operations: ['inspect', 'output'], + mutation: false, + }) + '\n', + ); + if (!readiness.ready) process.exitCode = 69; + return; + } finally { + sessionDigest.fill(0); + } + } + + const server = await startClusterCopilotConsoleServer({ + assets, + executor: Object.freeze({ + execute(command: Readonly) { + return executeClusterCopilotCommand({ + configFile: parsed.configFile, + credentialFile: parsed.credentialFile, + command, + }); + }, + }), + port: parsed.port, + sessionDigest, + }); + sessionDigest.fill(0); + process.stdout.write( + JSON.stringify({ + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-console', + event: 'started', + origin: server.origin, + listenAddress: '127.0.0.1', + browserCredential: 'forbidden', + clusterCredential: 'server_only', + operations: ['inspect', 'output'], + mutation: false, + }) + '\n', + ); + + await new Promise((resolve) => { + let stopping = false; + const stop = (): void => { + if (stopping) return; + stopping = true; + void server.close().finally(resolve); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + }); +} + +void main().catch(() => { + process.stderr.write( + JSON.stringify({ + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-console', + event: 'process_failed', + }) + '\n', + ); + process.exitCode = 1; +}); diff --git a/packages/ql3-cluster-admin/src/copilot-console/contracts.ts b/packages/ql3-cluster-admin/src/copilot-console/contracts.ts new file mode 100644 index 00000000..79260aec --- /dev/null +++ b/packages/ql3-cluster-admin/src/copilot-console/contracts.ts @@ -0,0 +1,86 @@ +import { + CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA, + type ClusterCopilotClientCommand, +} from '../copilot-client/contracts'; + +export const CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA = + 'qinglong/cluster-copilot-console-read-request@v1' as const; +export const CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA = + 'qinglong/cluster-copilot-console-read-response@v1' as const; + +export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output'; + +export interface ClusterCopilotConsoleReadRequest { + readonly schema: typeof CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA; + readonly operation: ClusterCopilotConsoleReadOperation; + readonly projectId: string; + readonly sourceRunId: string; + readonly requestId: string; +} + +export class InvalidClusterCopilotConsoleReadRequestError extends TypeError { + readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID'; + + constructor() { + super('Cluster Copilot Console read request is invalid'); + this.name = 'InvalidClusterCopilotConsoleReadRequestError'; + } +} + +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}$/; + +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(); + const expected = [ + 'operation', + 'projectId', + 'requestId', + 'schema', + 'sourceRunId', + ]; + if ( + keys.length !== expected.length || + keys.some((key, index) => key !== expected[index]) || + record.schema !== CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA || + (record.operation !== 'inspect' && record.operation !== 'output') || + 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({ + schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA, + operation: record.operation, + projectId: record.projectId, + sourceRunId: record.sourceRunId, + requestId: record.requestId, + }); +} + +export function clusterCopilotConsoleClientCommand( + request: Readonly, +): Readonly { + const normalized = normalizeClusterCopilotConsoleReadRequest(request); + return Object.freeze({ + schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA, + operation: normalized.operation, + projectId: normalized.projectId, + sourceRunId: normalized.sourceRunId, + requestId: normalized.requestId, + }); +} diff --git a/packages/ql3-cluster-admin/src/copilot-console/server.ts b/packages/ql3-cluster-admin/src/copilot-console/server.ts new file mode 100644 index 00000000..0930309a --- /dev/null +++ b/packages/ql3-cluster-admin/src/copilot-console/server.ts @@ -0,0 +1,498 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; + +import { + ClusterCopilotClientConfigurationError, + ClusterCopilotClientRemoteError, + ClusterCopilotClientRequestError, + type ClusterCopilotClientCommand, + type ClusterCopilotClientResult, +} from '../copilot-client/client'; +import { + type ClusterCopilotConsoleAssets, +} from './assets'; +import { + CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + InvalidClusterCopilotConsoleReadRequestError, + clusterCopilotConsoleClientCommand, + normalizeClusterCopilotConsoleReadRequest, +} from './contracts'; + +export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({ + maximumBodyBytes: 4 * 1024, + maximumResponseBytes: 2 * 1024 * 1024 + 4 * 1024, + maximumConcurrentRequests: 2, + maximumConnections: 16, + shutdownTimeoutMs: 2_000, +}); + +export interface ClusterCopilotConsoleExecutor { + execute( + command: Readonly, + ): Promise>; +} + +export interface ClusterCopilotConsoleServerOptions { + readonly assets: Readonly; + readonly executor: ClusterCopilotConsoleExecutor; + readonly port: number; + readonly sessionDigest: Buffer; +} + +export interface ClusterCopilotConsoleServer { + readonly origin: string; + close(): Promise; +} + +export class ClusterCopilotConsoleConfigurationError extends TypeError { + readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_CONFIG_INVALID'; + + constructor() { + super('Cluster Copilot Console configuration is invalid'); + this.name = 'ClusterCopilotConsoleConfigurationError'; + } +} + +const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/; +const SESSION_DIGEST_DOMAIN = Buffer.from( + 'qinglong-cluster-copilot-console-session-v1\0', + 'utf8', +); +const CONTENT_SECURITY_POLICY = [ + "default-src 'none'", + "base-uri 'none'", + "connect-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "script-src 'self'", + "style-src 'self'", + "img-src 'none'", + "font-src 'none'", + "object-src 'none'", + "media-src 'none'", + "manifest-src 'none'", + "worker-src 'none'", +].join('; '); + +function invalid(): never { + throw new ClusterCopilotConsoleConfigurationError(); +} + +function exactObject( + value: unknown, + keys: readonly string[], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalid(); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + return invalid(); + } + return record; +} + +export function clusterCopilotConsoleSessionDigest(value: string): Buffer { + if (typeof value !== 'string' || !SESSION_TOKEN.test(value)) { + return invalid(); + } + const decoded = Buffer.from(value, 'base64url'); + if ( + decoded.byteLength !== 32 || + decoded.toString('base64url') !== value + ) { + decoded.fill(0); + return invalid(); + } + decoded.fill(0); + return createHash('sha256') + .update(SESSION_DIGEST_DOMAIN) + .update(value, 'ascii') + .digest(); +} + +function securityHeaders(contentType: string): Readonly> { + return Object.freeze({ + 'cache-control': 'no-store', + 'content-security-policy': CONTENT_SECURITY_POLICY, + 'content-type': contentType, + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'same-origin', + 'origin-agent-cluster': '?1', + 'permissions-policy': + 'camera=(), display-capture=(), geolocation=(), microphone=(), payment=(), usb=()', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + }); +} + +function send( + response: ServerResponse, + statusCode: number, + contentType: string, + body: string, + extraHeaders: Readonly> = {}, +): void { + const bytes = Buffer.from(body, 'utf8'); + response.writeHead(statusCode, { + ...securityHeaders(contentType), + ...extraHeaders, + connection: 'close', + 'content-length': String(bytes.byteLength), + }); + response.end(bytes, () => bytes.fill(0)); +} + +function sendJson( + response: ServerResponse, + statusCode: number, + body: Readonly>, + extraHeaders: Readonly> = {}, +): void { + send( + response, + statusCode, + 'application/json; charset=utf-8', + JSON.stringify(body), + extraHeaders, + ); +} + +function headerCount(request: IncomingMessage, name: string): number { + let count = 0; + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (request.rawHeaders[index]?.toLowerCase() === name) count += 1; + } + return count; +} + +function targetPath(request: IncomingMessage): 'inspect' | 'output' | 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; +} + +function authorize( + request: IncomingMessage, + expectedOrigin: string, + sessionDigest: Buffer, +): boolean { + if ( + headerCount(request, 'authorization') !== 1 || + headerCount(request, 'origin') !== 1 || + request.headers.origin !== expectedOrigin || + request.headers.host !== expectedOrigin.slice('http://'.length) + ) { + return false; + } + const authorization = request.headers.authorization; + if ( + typeof authorization !== 'string' || + !authorization.startsWith('QL3-Console ') + ) { + return false; + } + let candidate: Buffer | undefined; + try { + candidate = clusterCopilotConsoleSessionDigest( + authorization.slice('QL3-Console '.length), + ); + return timingSafeEqual(candidate, sessionDigest); + } catch { + return false; + } finally { + candidate?.fill(0); + } +} + +async function readJsonBody(request: IncomingMessage): Promise { + if ( + headerCount(request, 'content-type') !== 1 || + headerCount(request, 'content-length') !== 1 || + request.headers['content-type'] !== 'application/json; charset=utf-8' || + request.headers['content-encoding'] !== undefined || + request.headers['transfer-encoding'] !== undefined || + typeof request.headers['content-length'] !== 'string' || + !/^[1-9][0-9]*$/.test(request.headers['content-length']) + ) { + throw new InvalidClusterCopilotConsoleReadRequestError(); + } + const expectedLength = Number(request.headers['content-length']); + if ( + !Number.isSafeInteger(expectedLength) || + expectedLength < 2 || + expectedLength > CLUSTER_COPILOT_CONSOLE_LIMITS.maximumBodyBytes + ) { + throw new InvalidClusterCopilotConsoleReadRequestError(); + } + const chunks: Buffer[] = []; + let length = 0; + try { + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + length += bytes.byteLength; + if ( + length > expectedLength || + length > CLUSTER_COPILOT_CONSOLE_LIMITS.maximumBodyBytes + ) { + throw new InvalidClusterCopilotConsoleReadRequestError(); + } + chunks.push(bytes); + } + if (request.aborted || length !== expectedLength) { + throw new InvalidClusterCopilotConsoleReadRequestError(); + } + const body = Buffer.concat(chunks, length); + try { + return JSON.parse(body.toString('utf8')); + } finally { + body.fill(0); + } + } catch (error) { + if (error instanceof InvalidClusterCopilotConsoleReadRequestError) { + throw error; + } + throw new InvalidClusterCopilotConsoleReadRequestError(); + } finally { + for (const chunk of chunks) chunk.fill(0); + } +} + +function remoteFailure( + response: ServerResponse, + error: ClusterCopilotClientRemoteError, +): void { + const statusCode = + error.statusCode === 404 + ? 404 + : error.statusCode === 429 + ? 429 + : 502; + sendJson( + response, + statusCode, + Object.freeze({ + schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + code: error.responseCode, + requestId: error.requestId, + retryAfterSeconds: error.retryAfterSeconds, + }), + error.retryAfterSeconds === null + ? {} + : { 'retry-after': String(error.retryAfterSeconds) }, + ); +} + +export async function startClusterCopilotConsoleServer( + options: ClusterCopilotConsoleServerOptions, +): Promise> { + const record = exactObject(options, [ + 'assets', + 'executor', + 'port', + 'sessionDigest', + ]); + const assets = exactObject(record.assets, ['css', 'html', 'javascript']); + if ( + typeof assets.html !== 'string' || + assets.html.length < 1 || + typeof assets.css !== 'string' || + assets.css.length < 1 || + typeof assets.javascript !== 'string' || + assets.javascript.length < 1 || + !record.executor || + typeof (record.executor as ClusterCopilotConsoleExecutor).execute !== + 'function' || + !Number.isSafeInteger(record.port) || + ((record.port as number) !== 0 && + ((record.port as number) < 1_024 || (record.port as number) > 65_535)) || + !Buffer.isBuffer(record.sessionDigest) || + (record.sessionDigest as Buffer).byteLength !== 32 + ) { + return invalid(); + } + const sessionDigest = Buffer.from(record.sessionDigest as Buffer); + const executor = record.executor as ClusterCopilotConsoleExecutor; + let expectedOrigin = ''; + let inFlight = 0; + let closed = false; + + const server = createServer(async (request, response) => { + response.shouldKeepAlive = false; + const hostMatches = + expectedOrigin !== '' && + request.headers.host === expectedOrigin.slice('http://'.length); + if (request.method === 'GET' && hostMatches) { + if (request.url === '/') { + send(response, 200, 'text/html; charset=utf-8', assets.html as string); + return; + } + if (request.url === '/app.css') { + send(response, 200, 'text/css; charset=utf-8', assets.css as string); + return; + } + if (request.url === '/app.js') { + send( + response, + 200, + 'text/javascript; charset=utf-8', + assets.javascript as string, + ); + return; + } + } + + const operation = targetPath(request); + if ( + !hostMatches || + operation === null || + !authorize(request, expectedOrigin, sessionDigest) + ) { + sendJson(response, 404, Object.freeze({ code: 'not_found' })); + request.resume(); + return; + } + if ( + inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests + ) { + sendJson( + response, + 429, + Object.freeze({ + schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + code: 'cluster_copilot_console_busy', + }), + { 'retry-after': '1' }, + ); + request.resume(); + return; + } + + inFlight += 1; + try { + const body = await readJsonBody(request); + const normalized = normalizeClusterCopilotConsoleReadRequest(body); + if (normalized.operation !== operation) { + throw new InvalidClusterCopilotConsoleReadRequestError(); + } + const result = await executor.execute( + clusterCopilotConsoleClientCommand(normalized), + ); + const envelope = Object.freeze({ + schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + operation, + requestId: result.requestId, + result, + }); + const encoded = JSON.stringify(envelope); + if ( + Buffer.byteLength(encoded, 'utf8') > + CLUSTER_COPILOT_CONSOLE_LIMITS.maximumResponseBytes + ) { + throw new ClusterCopilotClientRequestError(); + } + send( + response, + 200, + 'application/json; charset=utf-8', + encoded, + ); + } catch (error) { + if (response.headersSent) { + response.destroy(); + } else if ( + error instanceof InvalidClusterCopilotConsoleReadRequestError + ) { + sendJson( + response, + 400, + Object.freeze({ + schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + code: 'invalid_cluster_copilot_console_read_request', + }), + ); + } else if (error instanceof ClusterCopilotClientRemoteError) { + remoteFailure(response, error); + } else if ( + error instanceof ClusterCopilotClientConfigurationError || + error instanceof ClusterCopilotClientRequestError + ) { + sendJson( + response, + 503, + Object.freeze({ + schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + code: 'cluster_copilot_console_upstream_unavailable', + }), + ); + } else { + sendJson( + response, + 503, + Object.freeze({ + schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, + code: 'cluster_copilot_console_unavailable', + }), + ); + } + } finally { + inFlight -= 1; + } + }); + + server.maxConnections = CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConnections; + server.headersTimeout = 5_000; + server.requestTimeout = 5_000; + server.keepAliveTimeout = 1; + server.maxRequestsPerSocket = 1; + server.on('clientError', (_error, socket) => socket.destroy()); + + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(record.port as number, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') return invalid(); + expectedOrigin = 'http://127.0.0.1:' + String(address.port); + } catch (error) { + sessionDigest.fill(0); + server.closeAllConnections(); + if (error instanceof ClusterCopilotConsoleConfigurationError) throw error; + throw new ClusterCopilotConsoleConfigurationError(); + } + + return Object.freeze({ + origin: expectedOrigin, + async close(): Promise { + if (closed) return; + closed = true; + await new Promise((resolve) => { + const timeout = setTimeout(() => { + server.closeAllConnections(); + }, CLUSTER_COPILOT_CONSOLE_LIMITS.shutdownTimeoutMs); + timeout.unref(); + server.close(() => { + clearTimeout(timeout); + resolve(); + }); + server.closeIdleConnections(); + }); + sessionDigest.fill(0); + }, + }); +} diff --git a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts index ffb4cb39..da8e63d3 100644 --- a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts +++ b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts @@ -46,6 +46,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc target: 'copilot-mcp/cli.js', description: 'serve the bounded Cluster Copilot MCP over stdio', }), + Object.freeze({ + name: 'copilot-console', + binary: 'ql3-copilot-console', + target: 'copilot-console/cli.js', + description: 'open the loopback-only read-only Copilot Console', + }), Object.freeze({ name: 'package', binary: 'ql3-plugin-package-client', @@ -192,7 +198,7 @@ export function qingLong3ClusterProductHelp(): string { '', 'Use `ql3-cluster-admin --help` for command-specific usage.', 'Use `--context=/absolute/operator-context.json` only with remote client commands.', - 'Keep the MCP config explicit; it contains stable paths to a separately rotated credential.', + 'Keep MCP and Console authority explicit; neither belongs in operator context.', 'Command and short-lived assertion files always remain explicit per invocation.', 'Server, migration, recovery, executor and key-custody authorities remain isolated.', ].join('\n'); diff --git a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs new file mode 100644 index 00000000..27ae0452 --- /dev/null +++ b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs @@ -0,0 +1,412 @@ +const assert = require('node:assert/strict'); +const { randomBytes } = require('node:crypto'); +const { request: httpRequest } = require('node:http'); +const { mkdtemp, mkdir, cp, writeFile } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const test = require('node:test'); + +const { + ClusterCopilotClientRemoteError, +} = require('../dist/copilot-client/client.js'); +const { + ClusterCopilotConsoleAssetError, + loadClusterCopilotConsoleAssets, +} = require('../dist/copilot-console/assets.js'); +const { + CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA, + clusterCopilotConsoleClientCommand, + normalizeClusterCopilotConsoleReadRequest, +} = require('../dist/copilot-console/contracts.js'); +const { + clusterCopilotConsoleSessionDigest, + startClusterCopilotConsoleServer, +} = require('../dist/copilot-console/server.js'); + +const moduleDirectory = resolve(__dirname, '../dist/copilot-console'); + +function target(operation = 'inspect') { + return { + schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA, + operation, + projectId: 'project-main', + sourceRunId: 'run-source-1', + requestId: 'diagnosis-request-1', + }; +} + +function inspection() { + return { + schemaVersion: 1, + operation: 'inspect', + requestId: 'transport-read-1', + result: { + schema: 'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1', + status: 'terminal', + projectId: 'project-main', + sourceRunId: 'run-source-1', + requestId: 'diagnosis-request-1', + diagnosisRunId: 'run-diagnosis-1', + outcome: 'succeeded', + stage: 'model', + reason: null, + outputAvailable: true, + admittedAtMs: 1_700_000_000_000, + finalizedAtMs: 1_700_000_001_000, + usage: { + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + currency: 'USD', + costMicros: 42, + }, + }, + }; +} + +function output() { + const text = ''; + return { + schemaVersion: 1, + operation: 'output', + requestId: 'transport-read-2', + result: { + schema: 'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1', + status: 'available', + projectId: 'project-main', + sourceRunId: 'run-source-1', + requestId: 'diagnosis-request-1', + diagnosisRunId: 'run-diagnosis-1', + reference: { + artifactId: 'artifact-diagnosis-1', + artifactDigest: 'a'.repeat(64), + contentDigest: 'b'.repeat(64), + outputBytes: Buffer.byteLength(text), + sealedAtMs: 1_700_000_001_000, + }, + result: { + text, + finishReason: 'stop', + usage: { + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costMicros: 42, + }, + }, + }, + }; +} + +function request(origin, options = {}) { + const url = new URL(origin); + const body = + options.body === undefined + ? undefined + : Buffer.from(JSON.stringify(options.body), 'utf8'); + return new Promise((resolve, reject) => { + const outgoing = httpRequest( + { + hostname: '127.0.0.1', + port: Number(url.port), + method: options.method || 'GET', + path: options.path || '/', + agent: false, + headers: { + ...(options.headers || {}), + ...(body === undefined + ? {} + : { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(body.length), + }), + }, + }, + (incoming) => { + const chunks = []; + incoming.on('data', (chunk) => chunks.push(chunk)); + incoming.on('end', () => { + const bytes = Buffer.concat(chunks); + const text = bytes.toString('utf8'); + resolve({ + statusCode: incoming.statusCode, + headers: incoming.headers, + text, + body: + incoming.headers['content-type'] === + 'application/json; charset=utf-8' + ? JSON.parse(text) + : null, + }); + }); + }, + ); + outgoing.once('error', reject); + if (body !== undefined) outgoing.end(body); + else outgoing.end(); + }); +} + +async function fixture(execute = async () => inspection()) { + const token = randomBytes(32).toString('base64url'); + const server = await startClusterCopilotConsoleServer({ + assets: loadClusterCopilotConsoleAssets(moduleDirectory), + executor: { execute }, + port: 0, + sessionDigest: clusterCopilotConsoleSessionDigest(token), + }); + return { + token, + server, + headers: { + authorization: 'QL3-Console ' + token, + origin: server.origin, + }, + }; +} + +test('normalizes only the two read operations into the shared client contract', () => { + assert.deepEqual( + clusterCopilotConsoleClientCommand( + normalizeClusterCopilotConsoleReadRequest(target('inspect')), + ), + { + schema: 'qinglong/cluster-copilot-client-command@v1', + operation: 'inspect', + projectId: 'project-main', + sourceRunId: 'run-source-1', + requestId: 'diagnosis-request-1', + }, + ); + assert.equal( + clusterCopilotConsoleClientCommand(target('output')).operation, + 'output', + ); + assert.throws( + () => + normalizeClusterCopilotConsoleReadRequest({ + ...target(), + operation: 'diagnose', + }), + { code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' }, + ); + assert.throws( + () => + normalizeClusterCopilotConsoleReadRequest({ + ...target(), + mutationId: 'forbidden', + }), + { 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.css, /prefers-reduced-motion/); + assert.match(assets.javascript, /textContent = fact\.result\.text/); + 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 })); + const fakeModuleDirectory = join(root, 'dist', 'copilot-console'); + await mkdir(fakeModuleDirectory, { recursive: true }); + await cp( + resolve(moduleDirectory, '../../assets'), + join(root, 'assets'), + { recursive: true }, + ); + await writeFile( + join(root, 'assets', 'copilot-console', 'app.js'), + '"drift";\n', + ); + assert.throws( + () => loadClusterCopilotConsoleAssets(fakeModuleDirectory), + ClusterCopilotConsoleAssetError, + ); +}); + +test('serves an immutable same-origin shell with a closed browser policy', async (t) => { + const { server } = await fixture(); + t.after(() => server.close()); + const html = await request(server.origin); + assert.equal(html.statusCode, 200); + assert.equal(html.headers['cache-control'], 'no-store'); + 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/); + + 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'); +}); + +test('keeps the Cluster credential server-side and forwards one exact inspect', async (t) => { + const commands = []; + const { server, headers } = await fixture(async (command) => { + commands.push(command); + return inspection(); + }); + t.after(() => server.close()); + const response = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/inspect', + headers, + body: target('inspect'), + }); + assert.equal(response.statusCode, 200); + assert.deepEqual(commands, [ + { + schema: 'qinglong/cluster-copilot-client-command@v1', + operation: 'inspect', + projectId: 'project-main', + sourceRunId: 'run-source-1', + requestId: 'diagnosis-request-1', + }, + ]); + assert.equal( + response.body.schema, + 'qinglong/cluster-copilot-console-read-response@v1', + ); + assert.equal(response.body.result.result.outputAvailable, true); + assert.doesNotMatch(response.text, /ql3c_|authorization|credential/i); +}); + +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'); + return output(); + }); + t.after(() => server.close()); + const response = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/output', + headers, + body: target('output'), + }); + assert.equal(response.statusCode, 200); + assert.equal( + response.body.result.result.result.text, + '', + ); + assert.equal(response.headers['content-type'], 'application/json; charset=utf-8'); + assert.equal(response.headers['x-content-type-options'], 'nosniff'); +}); + +test('masks wrong Host, Origin, session and every non-read route', async (t) => { + let calls = 0; + const { server, token, headers } = await fixture(async () => { + calls += 1; + return inspection(); + }); + t.after(() => server.close()); + const cases = [ + { ...headers, origin: 'https://attacker.example' }, + { ...headers, authorization: 'QL3-Console ' + randomBytes(32).toString('base64url') }, + { ...headers, host: 'attacker.example' }, + ]; + for (const candidate of cases) { + const response = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/inspect', + headers: candidate, + body: target(), + }); + assert.equal(response.statusCode, 404); + assert.deepEqual(response.body, { code: 'not_found' }); + } + const mutation = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/diagnose', + headers: { + authorization: 'QL3-Console ' + token, + origin: server.origin, + }, + body: target(), + }); + assert.equal(mutation.statusCode, 404); + assert.equal(calls, 0); +}); + +test('rejects widened and route-confused read bodies before upstream authority', async (t) => { + let calls = 0; + const { server, headers } = await fixture(async () => { + calls += 1; + return inspection(); + }); + t.after(() => server.close()); + const widened = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/inspect', + headers, + body: { ...target(), endpoint: 'https://attacker.example' }, + }); + const confused = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/output', + headers, + body: target('inspect'), + }); + assert.equal(widened.statusCode, 400); + assert.equal(confused.statusCode, 400); + assert.equal(calls, 0); +}); + +test('rejects a third concurrent read without a hidden queue', async (t) => { + const releases = []; + const { server, headers } = await fixture( + () => + new Promise((resolve) => { + releases.push(() => resolve(inspection())); + }), + ); + t.after(() => server.close()); + const options = { + method: 'POST', + path: '/api/v1/copilot/inspect', + headers, + body: target(), + }; + const first = request(server.origin, options); + const second = request(server.origin, options); + while (releases.length < 2) { + await new Promise((resolve) => setImmediate(resolve)); + } + const third = await request(server.origin, options); + assert.equal(third.statusCode, 429); + assert.equal(third.body.code, 'cluster_copilot_console_busy'); + assert.equal(releases.length, 2); + releases.splice(0).forEach((release) => release()); + assert.equal((await first).statusCode, 200); + assert.equal((await second).statusCode, 200); +}); + +test('projects only bounded remote failure facts and closes idempotently', async () => { + const { server, headers } = await fixture(async () => { + throw new ClusterCopilotClientRemoteError( + 429, + 'project_read_rate_limited', + 'transport-read-3', + 7, + ); + }); + const response = await request(server.origin, { + method: 'POST', + path: '/api/v1/copilot/inspect', + headers, + body: target(), + }); + assert.equal(response.statusCode, 429); + assert.deepEqual(response.body, { + schema: 'qinglong/cluster-copilot-console-read-response@v1', + code: 'project_read_rate_limited', + requestId: 'transport-read-3', + retryAfterSeconds: 7, + }); + assert.equal(response.headers['retry-after'], '7'); + await server.close(); + await server.close(); +}); diff --git a/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs b/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs new file mode 100644 index 00000000..8e2c8919 --- /dev/null +++ b/packages/ql3-cluster-admin/test/copilotConsoleCli.test.cjs @@ -0,0 +1,261 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { randomBytes } = require('node:crypto'); +const fs = require('node:fs'); +const { request: httpRequest } = require('node:http'); +const { createServer } = require('node:https'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + CLUSTER_COPILOT_CLIENT_CONFIG_SCHEMA, +} = require('../dist/copilot-client/client.js'); + +const packageRoot = path.resolve(__dirname, '..'); +const cliPath = path.join(packageRoot, 'dist', 'copilot-console', 'cli.js'); +const tlsFixture = path.resolve( + packageRoot, + '../ql3-cluster-control/test/fixtures/mtls', +); +const credential = + 'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url'); + +function privateFile(directory, name, contents) { + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, contents, { mode: 0o600 }); + return fs.realpathSync(filePath); +} + +function runCli(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: packageRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + child.once('close', (status, signal) => { + resolve({ + status, + signal, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +} + +function firstLine(stream) { + return new Promise((resolve, reject) => { + let buffered = ''; + const receive = (chunk) => { + buffered += chunk.toString('utf8'); + const newline = buffered.indexOf('\n'); + if (newline === -1) return; + stream.off('data', receive); + stream.off('error', reject); + resolve(buffered.slice(0, newline)); + }; + stream.on('data', receive); + stream.once('error', reject); + }); +} + +function get(origin) { + const url = new URL(origin); + return new Promise((resolve, reject) => { + const request = httpRequest( + { + hostname: '127.0.0.1', + port: Number(url.port), + method: 'GET', + path: '/', + agent: false, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => + resolve({ + statusCode: response.statusCode, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + request.once('error', reject); + request.end(); + }); +} + +async function fixture(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-console-cli-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const requests = []; + const server = createServer( + { + key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')), + cert: fs.readFileSync(path.join(tlsFixture, 'server-cert.pem')), + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }, + (request, response) => { + requests.push({ + method: request.method, + path: request.url, + authorization: request.headers.authorization, + tls: request.socket.getProtocol(), + }); + const bytes = Buffer.from('{"status":"ready"}', 'utf8'); + response.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(bytes.byteLength), + }); + response.end(bytes); + }, + ); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after( + () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ); + const caFile = privateFile( + directory, + 'ca.pem', + fs.readFileSync(path.join(tlsFixture, 'ca-cert.pem')), + ); + const configFile = privateFile( + directory, + 'client.json', + JSON.stringify({ + schema: CLUSTER_COPILOT_CLIENT_CONFIG_SCHEMA, + endpoint: `https://localhost:${server.address().port}/`, + servername: 'localhost', + caFile, + requestTimeoutMs: 2_000, + }), + ); + return { + requests, + configFile, + credentialFile: privateFile(directory, 'credential', credential), + sessionFile: privateFile( + directory, + 'session', + randomBytes(32).toString('base64url'), + ), + }; +} + +test('CLI exposes deterministic help and a low-sensitive failure surface', async () => { + const usage = [ + 'Usage:', + ' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]', + ' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session', + '', + 'The Console binds only 127.0.0.1 and exposes inspect/output reads.', + 'The browser session key remains in a separate owner-private 0600 file.', + ].join('\n'); + assert.deepEqual(await runCli(['--help']), { + status: 0, + signal: null, + stdout: usage + '\n', + stderr: '', + }); + const failed = await runCli([ + '--config', + '/private/operator/client-secret.json', + '--credential', + '/private/operator/cluster-secret', + '--session', + '/private/operator/browser-secret', + ]); + assert.equal(failed.status, 1); + assert.equal(failed.stdout, ''); + assert.deepEqual(JSON.parse(failed.stderr), { + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-console', + event: 'process_failed', + }); + assert.doesNotMatch(failed.stderr, /client-secret|cluster-secret|browser-secret/); +}); + +test('preflight proves private authority and unauthenticated TLS 1.3 readiness', async (t) => { + const value = await fixture(t); + const result = await runCli([ + '--check', + '--config', + value.configFile, + '--credential', + value.credentialFile, + '--session', + value.sessionFile, + ]); + assert.equal(result.status, 0); + assert.equal(result.stderr, ''); + assert.deepEqual(JSON.parse(result.stdout), { + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-console', + event: 'preflight_checked', + ready: true, + listenAddress: '127.0.0.1', + browserCredential: 'forbidden', + clusterCredential: 'server_only', + operations: ['inspect', 'output'], + mutation: false, + }); + assert.deepEqual(value.requests, [ + { + method: 'GET', + path: '/readyz', + authorization: undefined, + tls: 'TLSv1.3', + }, + ]); +}); + +test('serve mode starts an ephemeral loopback origin and shuts down cleanly', async (t) => { + const value = await fixture(t); + const child = spawn( + process.execPath, + [ + cliPath, + '--config', + value.configFile, + '--credential', + value.credentialFile, + '--session', + value.sessionFile, + '--port=0', + ], + { cwd: packageRoot, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + t.after(() => { + 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.equal(started.mutation, false); + const shell = await get(started.origin); + assert.equal(shell.statusCode, 200); + assert.match(shell.body, /Cluster field console/); + child.kill('SIGTERM'); + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (status, signal) => resolve({ status, signal })); + }); + assert.deepEqual(result, { status: 0, signal: null }); +}); diff --git a/packages/ql3-cluster-admin/test/productCli.test.cjs b/packages/ql3-cluster-admin/test/productCli.test.cjs index d0e361c6..5628bc8d 100644 --- a/packages/ql3-cluster-admin/test/productCli.test.cjs +++ b/packages/ql3-cluster-admin/test/productCli.test.cjs @@ -333,7 +333,7 @@ function validContextFixture(t) { test('catalog exposes only reviewed product entrypoints from the same package', () => { assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js'); - assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 9); + assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 10); assert.equal( new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size, QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, @@ -351,7 +351,8 @@ test('catalog exposes only reviewed product entrypoints from the same package', ); assert.equal( command.binary.includes('-client') || - command.binary === 'ql3-copilot-mcp', + command.binary === 'ql3-copilot-mcp' || + command.binary === 'ql3-copilot-console', true, ); } @@ -380,6 +381,7 @@ test('help and version are bounded installation-derived product facts', () => { assert.match(help, /\n run\s+retry or stop Runs/); assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/); assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/); + assert.match(help, /\n copilot-console\s+open the loopback-only read-only/); assert.match(help, /Server, migration, recovery, executor and key-custody/); assert.equal(help.includes('plugin-package-manage'), false); assert.equal( diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index 78e3acbc..6ebc29d6 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -17,6 +17,10 @@ const COMMANDS = Object.freeze([ name: 'copilot-mcp', usage: 'Usage: ql3-copilot-mcp --config ', }), + Object.freeze({ + name: 'copilot-console', + usage: 'Usage:\n ql3-copilot-console --config ', + }), Object.freeze({ name: 'package', usage: 'Usage: ql3-plugin-package-client ', @@ -206,6 +210,97 @@ process.stdout.write(JSON.stringify({ schemaVersion: 1, injected: true, contextP } } +function runConsoleContract(image) { + const source = String.raw` +const { spawn } = require('node:child_process'); +const { writeFileSync } = require('node:fs'); +const { get } = require('node:http'); +const { rootCertificates } = require('node:tls'); +const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js'; +const config = '/tmp/copilot-client.json'; +const credential = '/tmp/copilot-credential'; +const session = '/tmp/copilot-session'; +writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 }); +writeFileSync(config, JSON.stringify({ schema: 'qinglong/cluster-copilot-client-config@v1', endpoint: 'https://localhost:65535/', servername: 'localhost', caFile: '/tmp/ca.pem', requestTimeoutMs: 1000 }), { mode: 0o600 }); +writeFileSync(credential, 'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url'), { mode: 0o600 }); +writeFileSync(session, 'A'.repeat(43), { mode: 0o600 }); +const child = spawn(process.execPath, [facade, 'copilot-console', '--config', config, '--credential', credential, '--session', session, '--port=0'], { stdio: ['ignore', 'pipe', 'pipe'] }); +let stdout = ''; +let settled = false; +const timeout = setTimeout(() => finish(41), 5000); +function finish(code) { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + process.exitCode = code; +} +child.once('error', () => finish(42)); +child.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + const newline = stdout.indexOf('\n'); + 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; } + 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; } + child.once('close', (status, signal) => { + if (status !== 0 || signal !== null) { finish(46); return; } + settled = true; + clearTimeout(timeout); + process.stdout.write(JSON.stringify({ loopback: true, assets: true, cleanShutdown: true })); + }); + child.kill('SIGTERM'); + }); + }).once('error', () => finish(47)); +}); +`; + const output = docker([ + 'run', + '--rm', + '--read-only', + '--network', + 'none', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '10001:10001', + '--pids-limit', + '32', + '--memory', + '128m', + '--cpus', + '0.25', + '--tmpfs', + '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001', + '--entrypoint', + 'node', + image, + '-e', + source, + ]); + let result; + try { + result = JSON.parse(output); + } catch { + fail('Console live result is invalid'); + } + if ( + result?.loopback !== true || + result?.assets !== true || + result?.cleanShutdown !== true + ) { + fail('Console live contract drifted'); + } +} + function main() { if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') { fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required'); @@ -245,6 +340,7 @@ function main() { const version = runImage(image, ['--version']).trim(); if (version !== '3.0.0-alpha.0') fail('product version contract drifted'); runOperatorContextContract(image); + runConsoleContract(image); process.stdout.write( `${JSON.stringify({ @@ -257,6 +353,8 @@ function main() { operatorContext: true, contextPreflight: true, contextReadiness: true, + consoleLoopback: true, + consoleAssets: true, isolation: Object.freeze({ readOnlyRoot: true, network: 'none', diff --git a/scripts/ql3-cluster-copilot-console-audit.cjs b/scripts/ql3-cluster-copilot-console-audit.cjs new file mode 100644 index 00000000..63c0fa15 --- /dev/null +++ b/scripts/ql3-cluster-copilot-console-audit.cjs @@ -0,0 +1,319 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const CONSOLE_ROOT = 'packages/ql3-cluster-admin/src/copilot-console'; +const ASSET_ROOT = 'packages/ql3-cluster-admin/assets/copilot-console'; +const DEPLOYMENT_ROOT = 'deploy/console/ql3-cluster-copilot'; +const REQUIRED_FILES = Object.freeze([ + CONSOLE_ROOT + '/assets.ts', + CONSOLE_ROOT + '/cli.ts', + CONSOLE_ROOT + '/contracts.ts', + CONSOLE_ROOT + '/server.ts', + ASSET_ROOT + '/index.html', + ASSET_ROOT + '/app.css', + ASSET_ROOT + '/app.js', + DEPLOYMENT_ROOT + '/README.md', + DEPLOYMENT_ROOT + '/client-config.example.json', + 'deploy/containers/ql3-cluster-admin/Dockerfile', + 'scripts/ql3-cluster-admin-product-live-contract.cjs', +]); + +function finding(code, target, detail) { + return Object.freeze({ code, target, detail }); +} + +function filesBelow(root, relativeDirectory) { + const absolute = path.join(root, relativeDirectory); + const result = []; + const pending = [absolute]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const candidate = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(candidate); + else if (entry.isFile()) result.push(path.relative(root, candidate)); + } + } + return result.sort(); +} + +function auditClusterCopilotConsole(options = {}) { + const root = options.root || path.resolve(__dirname, '..'); + const readFile = + options.readFile || + ((relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8')); + const findings = []; + const source = {}; + + for (const relativePath of REQUIRED_FILES) { + try { + source[relativePath] = readFile(relativePath); + } catch (error) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_FILE_MISSING', + relativePath, + error instanceof Error ? error.name : 'Error', + ), + ); + } + } + + const expectFragments = (relativePath, fragments) => { + const contents = source[relativePath]; + if (typeof contents !== 'string') return; + for (const fragment of fragments) { + if (!contents.includes(fragment)) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_CONTRACT_MISSING', + relativePath, + fragment, + ), + ); + } + } + }; + const rejectFragments = (relativePath, fragments) => { + const contents = source[relativePath]; + if (typeof contents !== 'string') return; + for (const fragment of fragments) { + if (contents.includes(fragment)) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_AUTHORITY_WIDENED', + relativePath, + fragment, + ), + ); + } + } + }; + + expectFragments(CONSOLE_ROOT + '/contracts.ts', [ + "export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output'", + 'CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA', + 'clusterCopilotConsoleClientCommand', + ]); + rejectFragments(CONSOLE_ROOT + '/contracts.ts', [ + "| 'diagnose'", + "| 'cancel'", + 'mutationId', + 'traceId', + 'endpoint', + 'credential', + ]); + expectFragments(CONSOLE_ROOT + '/server.ts', [ + "server.listen(record.port as number, '127.0.0.1'", + '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'", + "default-src 'none'", + "frame-ancestors 'none'", + "'cache-control': 'no-store'", + ]); + rejectFragments(CONSOLE_ROOT + '/server.ts', [ + "'0.0.0.0'", + 'createSecureServer', + 'WebSocket', + 'set-cookie', + 'diagnose', + 'cancel', + 'child_process', + 'node:fs', + 'node:net', + ]); + expectFragments(CONSOLE_ROOT + '/cli.ts', [ + '--session /absolute/session', + 'readCanonicalFile(', + "'private'", + 'validateClusterCopilotClientCredentialFile', + "clusterCredential: 'server_only'", + "operations: ['inspect', 'output']", + 'mutation: false', + ]); + rejectFragments(CONSOLE_ROOT + '/cli.ts', [ + 'process.env', + '0.0.0.0', + 'diagnose', + 'cancel', + ]); + expectFragments(ASSET_ROOT + '/index.html', [ + '故障诊断,不替你执行。', + '只读边界', + '显式读取诊断内容', + '不可信模型输出', + ]); + expectFragments(ASSET_ROOT + '/app.js', [ + 'credentials: "omit"', + 'cache: "no-store"', + 'outputText.textContent = fact.result.text', + 'sessionToken = ""', + ]); + rejectFragments(ASSET_ROOT + '/app.js', [ + 'localStorage', + 'sessionStorage', + 'innerHTML', + 'eval(', + 'new Function', + 'WebSocket', + 'EventSource', + 'diagnose', + 'cancel', + 'http://', + 'https://', + ]); + expectFragments(ASSET_ROOT + '/app.css', [ + '@media (max-width: 520px)', + '@media (prefers-reduced-motion: reduce)', + ':focus-visible', + ]); + expectFragments(DEPLOYMENT_ROOT + '/README.md', [ + 'operator-workstation process', + 'Do not deploy it as a Kubernetes workload', + 'only `inspect` and explicit `output` reads', + '--port=0', + 'TLS 1.3 `GET /readyz`', + 'excluded from small router Edge/Standalone artifacts', + ]); + rejectFragments(DEPLOYMENT_ROOT + '/README.md', [ + '--host=0.0.0.0', + 'kubectl apply', + 'localStorage', + ]); + expectFragments('deploy/containers/ql3-cluster-admin/Dockerfile', [ + 'COPY --from=workspace /workspace/packages/ql3-cluster-admin/assets/copilot-console', + 'node_modules/@qinglong/cluster-admin/assets/copilot-console', + ]); + expectFragments('scripts/ql3-cluster-admin-product-live-contract.cjs', [ + 'function runConsoleContract(image)', + "[facade, 'copilot-console'", + "started.event !== 'started'", + "body.includes('Cluster field console')", + 'runConsoleContract(image);', + 'consoleLoopback: true', + 'consoleAssets: true', + ]); + + let manifest; + try { + manifest = JSON.parse(readFile('packages/ql3-cluster-admin/package.json')); + } catch (error) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_PACKAGE_INVALID', + 'packages/ql3-cluster-admin/package.json', + error instanceof Error ? error.name : 'Error', + ), + ); + } + if ( + manifest?.bin?.['ql3-copilot-console'] !== + 'dist/copilot-console/cli.js' || + manifest?.exports?.['./copilot-console']?.require !== + './dist/copilot-console/server.js' || + !Array.isArray(manifest?.files) || + !manifest.files.includes('assets/copilot-console/*') + ) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_PACKAGE_INVALID', + 'packages/ql3-cluster-admin/package.json', + 'bin, export or asset packlist drifted', + ), + ); + } + + let productCommand = ''; + try { + productCommand = readFile( + 'packages/ql3-cluster-admin/src/product-cli/productCommand.ts', + ); + } catch {} + if ( + !productCommand.includes("name: 'copilot-console'") || + !productCommand.includes("binary: 'ql3-copilot-console'") || + !productCommand.includes("target: 'copilot-console/cli.js'") + ) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_PRODUCT_ENTRY_MISSING', + 'packages/ql3-cluster-admin/src/product-cli/productCommand.ts', + 'static product delegation is incomplete', + ), + ); + } + + for (const relativePath of filesBelow(root, 'src')) { + const contents = readFile(relativePath); + if ( + contents.includes('ql3-copilot-console') || + contents.includes('cluster-copilot-console-read') || + contents.includes('copilot/failure-diagnoses') + ) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_LEGACY_UI_COUPLED', + relativePath, + 'legacy src imports or routes the QingLong 3.0 Console', + ), + ); + } + } + for (const relativePath of filesBelow(root, 'back')) { + const contents = readFile(relativePath); + if ( + contents.includes('ql3-copilot-console') || + contents.includes('cluster-copilot-console-read') + ) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_LEGACY_BACKEND_COUPLED', + relativePath, + 'legacy backend owns the QingLong 3.0 Console', + ), + ); + } + } + for (const relativePath of filesBelow(root, 'deploy/kubernetes')) { + if (!/\.ya?ml$/u.test(relativePath)) continue; + const contents = readFile(relativePath); + if (contents.includes('ql3-copilot-console')) { + findings.push( + finding( + 'CLUSTER_COPILOT_CONSOLE_KUBERNETES_RESIDENT', + relativePath, + 'operator-workstation Console must not be a Kubernetes workload', + ), + ); + } + } + + return Object.freeze({ + schemaVersion: 1, + component: 'cluster-copilot-console', + owner: '@qinglong/cluster-admin', + lifecycle: 'operator-workstation-loopback', + operations: Object.freeze(['inspect', 'output']), + legacyUiCoupled: false, + kubernetesResident: false, + assetCount: 3, + sourceFileCount: 4, + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +function main() { + const report = auditClusterCopilotConsole(); + process.stdout.write(JSON.stringify(report) + '\n'); + if (!report.compatible) process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { auditClusterCopilotConsole }; diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index d76bf932..a47d03c8 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -3101,6 +3101,9 @@ function auditPackageScripts(packagePath, manifest, findings) { function auditPackageFiles(packagePath, manifest, findings) { const expected = ['dist/**/*.js', 'dist/**/*.d.ts']; + if (packagePath === 'packages/ql3-cluster-admin') { + expected.push('assets/copilot-console/*'); + } if (packagePath === 'packages/ql3-local-process') expected.push('assets'); if (packagePath === 'packages/ql3-local-sqlite') expected.push('drizzle'); if (JSON.stringify(manifest.files) !== JSON.stringify(expected)) { diff --git a/scripts/ql3-cluster-oci-layout-audit.cjs b/scripts/ql3-cluster-oci-layout-audit.cjs index 2a830ee1..aded4723 100644 --- a/scripts/ql3-cluster-oci-layout-audit.cjs +++ b/scripts/ql3-cluster-oci-layout-audit.cjs @@ -240,7 +240,7 @@ function expectedImageConfig(architecture, revision, image) { ? isControlAi ? 'Optional QingLong 3.0 AI-enabled cluster control plane' : 'QingLong 3.0 PostgreSQL-backed cluster control plane' - : 'QingLong 3.0 cluster operations and bounded stdio MCP', + : 'QingLong 3.0 cluster operations and bounded Copilot surfaces', 'org.opencontainers.image.licenses': 'Apache-2.0', 'org.opencontainers.image.revision': revision, 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', diff --git a/test/back/ql3ClusterAdminProductLiveContract.test.cjs b/test/back/ql3ClusterAdminProductLiveContract.test.cjs index d0a36460..ae6e09fa 100644 --- a/test/back/ql3ClusterAdminProductLiveContract.test.cjs +++ b/test/back/ql3ClusterAdminProductLiveContract.test.cjs @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); const path = require('node:path'); const { test } = require('node:test'); const { @@ -47,3 +48,13 @@ test('fails closed before Docker without explicit opt-in', () => { assert.match(result.stderr, /QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required/); assert.equal(result.stderr.includes('spawn'), false); }); + +test('binds the live image gate to loopback Console assets and shutdown', () => { + 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, /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 new file mode 100644 index 00000000..415bbf53 --- /dev/null +++ b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs @@ -0,0 +1,155 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + auditClusterCopilotConsole, +} = require('../../scripts/ql3-cluster-copilot-console-audit.cjs'); + +const root = path.resolve(__dirname, '../..'); + +function intercept(target, mutate) { + return (relativePath) => { + const source = fs.readFileSync(path.join(root, relativePath), 'utf8'); + return relativePath === target ? mutate(source) : source; + }; +} + +test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => { + const report = auditClusterCopilotConsole({ root }); + assert.deepEqual(report, { + schemaVersion: 1, + component: 'cluster-copilot-console', + owner: '@qinglong/cluster-admin', + lifecycle: 'operator-workstation-loopback', + operations: ['inspect', 'output'], + legacyUiCoupled: false, + kubernetesResident: false, + assetCount: 3, + sourceFileCount: 4, + findings: [], + compatible: true, + }); +}); + +test('rejects a remote listener or mutation vocabulary', () => { + const listener = auditClusterCopilotConsole({ + root, + readFile: intercept( + 'packages/ql3-cluster-admin/src/copilot-console/server.ts', + (source) => source.replaceAll('127.0.0.1', '0.0.0.0'), + ), + }); + const mutation = auditClusterCopilotConsole({ + root, + readFile: intercept( + 'packages/ql3-cluster-admin/src/copilot-console/contracts.ts', + (source) => + source.replace( + "'inspect' | 'output'", + "'inspect' | 'output' | 'cancel'", + ), + ), + }); + assert.equal(listener.compatible, false); + assert.equal(mutation.compatible, false); + assert.ok( + listener.findings.some( + ({ code }) => code === 'CLUSTER_COPILOT_CONSOLE_CONTRACT_MISSING', + ), + ); + assert.ok( + mutation.findings.some( + ({ code }) => code === 'CLUSTER_COPILOT_CONSOLE_AUTHORITY_WIDENED', + ), + ); +}); + +test('rejects browser persistence, dynamic rendering and product drift', () => { + for (const injected of ['localStorage', 'innerHTML', 'WebSocket']) { + const report = auditClusterCopilotConsole({ + root, + readFile: intercept( + 'packages/ql3-cluster-admin/assets/copilot-console/app.js', + (source) => source + '\n// ' + injected + '\n', + ), + }); + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'CLUSTER_COPILOT_CONSOLE_AUTHORITY_WIDENED', + ), + ); + } + const product = auditClusterCopilotConsole({ + root, + readFile: intercept( + 'packages/ql3-cluster-admin/src/product-cli/productCommand.ts', + (source) => source.replace("name: 'copilot-console'", "name: 'removed'"), + ), + }); + assert.equal(product.compatible, false); + assert.ok( + product.findings.some( + ({ code }) => code === 'CLUSTER_COPILOT_CONSOLE_PRODUCT_ENTRY_MISSING', + ), + ); + const image = auditClusterCopilotConsole({ + root, + readFile: intercept( + 'deploy/containers/ql3-cluster-admin/Dockerfile', + (source) => + source.replaceAll( + 'packages/ql3-cluster-admin/assets/copilot-console', + 'packages/ql3-cluster-admin/assets/removed', + ), + ), + }); + assert.equal(image.compatible, false); + assert.ok( + image.findings.some( + ({ code, target }) => + code === 'CLUSTER_COPILOT_CONSOLE_CONTRACT_MISSING' && + target === 'deploy/containers/ql3-cluster-admin/Dockerfile', + ), + ); +}); + +test('rejects coupling into the legacy UI or Kubernetes workloads', () => { + const legacyTarget = 'src/pages/login/index.tsx'; + const legacy = auditClusterCopilotConsole({ + root, + readFile: intercept( + legacyTarget, + (source) => source + '\n// ql3-copilot-console\n', + ), + }); + const kubernetesTarget = + 'deploy/kubernetes/ql3-cluster/base/deployment.yaml'; + const kubernetes = auditClusterCopilotConsole({ + root, + readFile: intercept( + kubernetesTarget, + (source) => source + '\n# ql3-copilot-console\n', + ), + }); + assert.equal(legacy.compatible, false); + assert.equal(kubernetes.compatible, false); + assert.ok( + legacy.findings.some( + ({ code, target }) => + code === 'CLUSTER_COPILOT_CONSOLE_LEGACY_UI_COUPLED' && + target === legacyTarget, + ), + ); + assert.ok( + kubernetes.findings.some( + ({ code, target }) => + code === 'CLUSTER_COPILOT_CONSOLE_KUBERNETES_RESIDENT' && + target === kubernetesTarget, + ), + ); +}); diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index 3821ca56..f27eaa14 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -14,6 +14,10 @@ const { test('ships runtime JavaScript and declarations without development maps', () => { for (const [packagePath, files] of [ ['packages/ql3-runtime-core', ['dist/**/*.js', 'dist/**/*.d.ts']], + [ + 'packages/ql3-cluster-admin', + ['dist/**/*.js', 'dist/**/*.d.ts', 'assets/copilot-console/*'], + ], [ 'packages/ql3-local-process', ['dist/**/*.js', 'dist/**/*.d.ts', 'assets'], diff --git a/test/back/ql3ClusterOciLayoutAudit.test.cjs b/test/back/ql3ClusterOciLayoutAudit.test.cjs index 652c055a..412f8f7e 100644 --- a/test/back/ql3ClusterOciLayoutAudit.test.cjs +++ b/test/back/ql3ClusterOciLayoutAudit.test.cjs @@ -135,7 +135,7 @@ function createFixture(t, options = {}) { ? isControlAi ? 'Optional QingLong 3.0 AI-enabled cluster control plane' : 'QingLong 3.0 PostgreSQL-backed cluster control plane' - : 'QingLong 3.0 cluster operations and bounded stdio MCP', + : 'QingLong 3.0 cluster operations and bounded Copilot surfaces', 'org.opencontainers.image.licenses': 'Apache-2.0', 'org.opencontainers.image.revision': revision, 'org.opencontainers.image.source': diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index f06a61d9..d788e8e3 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 116, + sourceFiles: 120, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 115, + nestedSourceFiles: 119, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', },