From da4e7cf688113e57fbe12129708846930bcdb1cd Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 03:41:38 +0800 Subject: [PATCH] feat(ql3): harden copilot mcp host deployment --- .../containers/ql3-cluster-admin/Dockerfile | 2 +- .../containers/ql3-cluster-admin/package.json | 2 +- .../runtime-dependencies/package.json | 2 +- deploy/kubernetes/ql3-cluster/README.md | 28 ++-- .../ql3-cluster/operator-context.example.json | 3 + deploy/mcp/ql3-cluster-copilot/README.md | 67 +++++++++ .../client-config.example.json | 7 + .../mcp/ql3-cluster-copilot/docker-stdio.sh | 83 +++++++++++ .../mcp-config.example.json | 6 + .../ql3-cluster-copilot/mcp-host.example.json | 14 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...cit-cluster-copilot-mcp-host-deployment.md | 41 ++++++ docs/adr/README.md | 1 + packages/ql3-cluster-admin/README.md | 8 +- packages/ql3-cluster-admin/package.json | 2 +- .../ql3-cluster-admin/src/copilot-mcp/cli.ts | 72 +++++++++- .../src/product-cli/productCommand.ts | 11 +- .../test/copilotMcpStdio.test.cjs | 117 ++++++++++++++-- .../test/productCli.test.cjs | 11 +- ...l3-cluster-admin-product-live-contract.cjs | 12 +- scripts/ql3-cluster-deployment-audit.cjs | 106 ++++++++++++++ scripts/ql3-cluster-oci-layout-audit.cjs | 4 +- .../ql3ClusterCopilotMcpLauncher.test.cjs | 129 ++++++++++++++++++ test/back/ql3ClusterDeploymentAudit.test.cjs | 49 ++++++- test/back/ql3ClusterOciLayoutAudit.test.cjs | 4 +- 25 files changed, 734 insertions(+), 48 deletions(-) create mode 100644 deploy/mcp/ql3-cluster-copilot/README.md create mode 100644 deploy/mcp/ql3-cluster-copilot/client-config.example.json create mode 100755 deploy/mcp/ql3-cluster-copilot/docker-stdio.sh create mode 100644 deploy/mcp/ql3-cluster-copilot/mcp-config.example.json create mode 100644 deploy/mcp/ql3-cluster-copilot/mcp-host.example.json create mode 100644 docs/adr/ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md create mode 100644 test/back/ql3ClusterCopilotMcpLauncher.test.cjs diff --git a/deploy/containers/ql3-cluster-admin/Dockerfile b/deploy/containers/ql3-cluster-admin/Dockerfile index 35b2c08e..cc4ea11c 100644 --- a/deploy/containers/ql3-cluster-admin/Dockerfile +++ b/deploy/containers/ql3-cluster-admin/Dockerfile @@ -80,7 +80,7 @@ FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd3 ARG SOURCE_REVISION=uncommitted LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \ - org.opencontainers.image.description="QingLong 3.0 short-lived cluster administration jobs" \ + org.opencontainers.image.description="QingLong 3.0 cluster operations and bounded stdio MCP" \ 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 0e4e3225..8d823e91 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 the QingLong 3.0 short-lived cluster admin image", + "description": "Locked external dependencies for QingLong 3.0 cluster operations and bounded stdio MCP", "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 c6a9140b..27d70c84 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 the QingLong 3.0 short-lived cluster admin image", + "description": "Production-only external dependency root for QingLong 3.0 cluster operations and bounded stdio MCP", "license": "Apache-2.0", "engines": { "node": ">=24.18.0 <25" diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 895b718a..c648415b 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -34,16 +34,18 @@ docker build \ ``` The admin image has its own builder and production locks. Its five production -roots resolve to 84 external packages and it adds only `runtime-core`, -`cluster-postgres` and `cluster-admin`. Kubernetes client code and the admin -database role therefore exist only in an ephemeral Job image, never in -`cluster-control`. +roots resolve to 87 external packages and it adds only `runtime-core`, `ai`, +`cluster-postgres` and `cluster-admin`. Kubernetes client code and admin +database roles remain outside `cluster-control`; explicit operation commands +stay short-lived, while the Copilot MCP subpath loads only its remote API +client and stdio transport. Its default entrypoint is the bounded `ql3-cluster-admin` product facade. The -facade exposes only seven operator-facing remote clients: `package`, +facade exposes eight operator-facing remote clients: `copilot`, `package`, `package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and -`model-credential`. It delegates with the current Node executable, an exact -same-image target and `shell=false`; arguments remain opaque. Server +`model-credential`, plus the bounded `copilot-mcp` stdio process. It delegates +with the current Node executable, an exact same-image target and `shell=false`; +arguments and MCP stdio remain opaque. Server `*-manage` processes, migration, recovery, executors, Prompt output key custody and garbage collection remain separate explicit binaries and Kubernetes operations. Existing Jobs continue to name their exact binary and do not @@ -69,6 +71,14 @@ contents are deliberately forbidden. There is no home-directory, environment or ambient Kubernetes context discovery. Existing explicit `--config` calls remain supported when `--context` is absent. +Cluster Copilot MCP is deployed by its external MCP host, not as a Kubernetes +Deployment or Service. The reviewed digest-pinned Docker launcher, compact / +standard / dense resource ceilings, private projection examples and one-shot +readiness check live in +[`deploy/mcp/ql3-cluster-copilot`](../../mcp/ql3-cluster-copilot). This host +adapter is never composed into Edge/Standalone, Cluster Control, Cluster AI or +the shared operations Kustomization. + Validate the complete local operator context before a maintenance window: ```sh @@ -78,7 +88,7 @@ ql3-cluster-admin context validate \ This command reuses the production client parsers to validate every selected endpoint shape, CA, matching client certificate/private key and the bounded -Kubernetes config. It also verifies that all seven facade targets exist in the +Kubernetes config. It also verifies that all nine facade targets exist in the same installation. It does not read a command or assertion, open a network connection, query Kubernetes or mutate the cluster. Its JSON summary contains only command names and reviewed transport/authentication classes; paths, @@ -99,7 +109,7 @@ pnpm sbom:cluster-image:ql3 --image=admin pnpm audit:image-release:ql3 ``` -The admin image resolves 84 external plus 3 internal components. Its production +The admin image resolves 87 external plus 4 internal components. Its production closure legitimately contains the upstream runtime dependency `@types/js-yaml` from `@kubernetes/client-node`; the exact production lock, not the package-name prefix, is authoritative. TypeScript, root devDependencies and diff --git a/deploy/kubernetes/ql3-cluster/operator-context.example.json b/deploy/kubernetes/ql3-cluster/operator-context.example.json index 327fd698..983afe3c 100644 --- a/deploy/kubernetes/ql3-cluster/operator-context.example.json +++ b/deploy/kubernetes/ql3-cluster/operator-context.example.json @@ -1,6 +1,9 @@ { "schemaVersion": 1, "commands": { + "copilot": { + "configFile": "/secure/qinglong3/copilot-client.json" + }, "package": { "configFile": "/secure/qinglong3/plugin-package-client.json" }, diff --git a/deploy/mcp/ql3-cluster-copilot/README.md b/deploy/mcp/ql3-cluster-copilot/README.md new file mode 100644 index 00000000..89ade2b6 --- /dev/null +++ b/deploy/mcp/ql3-cluster-copilot/README.md @@ -0,0 +1,67 @@ +# Cluster Copilot MCP stdio container + +This is an explicit host-side deployment for the bounded Cluster Copilot MCP. +It is not a Kubernetes Deployment or Service: stdio must be owned by the MCP +host that launches the process. A resident Pod without that parent session +would be unreachable while still retaining a credential and image attack +surface. + +The launcher uses the existing independently released Cluster Admin image and +its default `ql3-cluster-admin` entrypoint. It selects the reviewed +`copilot-mcp` subcommand, never overrides the entrypoint and never mounts a +Kubernetes token, database credential, Docker socket or writable directory. + +## Prepare the private projection + +Create an absolute canonical directory owned by UID/GID `10001:10001`, mode +`0700`. Copy `mcp-config.example.json` to `mcp.json` and +`client-config.example.json` to `client.json`; install the reviewed API CA as +`ca.pem` and the separately issued `ql3c_` Project API credential as +`credential`. All four files must be regular, non-symlink, UID 10001-owned, +canonical files with mode `0600`. Never put the credential value in the MCP +host config, argv, environment or image. + +Replace the endpoint, DNS server name and CA. The client uses TLS 1.3, does not +use a client certificate, proxy, redirect or ambient CA, and rereads +`credential` for every Tool call. + +## Select resources and egress + +Create a dedicated Docker network whose host firewall permits only DNS and the +reviewed Cluster API destination. The launcher rejects `bridge`, `default`, +`host`, `none` and an implicit network, but Docker network naming alone is not +an egress allowlist. + +| Resource class | Memory | CPU | PIDs | Maximum configured concurrency | +| --- | ---: | ---: | ---: | ---: | +| `compact` | 192 MiB | 0.25 | 32 | 1 | +| `standard` | 512 MiB | 1 | 64 | 4 | +| `dense` | 1 GiB | 2 | 96 | 16 | + +The launcher passes a second concurrency ceiling to the process. Startup and +preflight fail closed if `mcp.json` requests more concurrency than its resource +class. There is no queue, retry, poller, watcher or resident health timer. + +Export only the immutable image digest, private directory path, dedicated +network name and resource class, then validate before registering the host: + +```sh +deploy/mcp/ql3-cluster-copilot/docker-stdio.sh check +``` + +The check validates all mounted path/credential authority and makes one +unauthenticated `GET /readyz`. Its JSON contains no endpoint, path, credential +or cluster identity. A not-ready response exits 69; invalid local authority or +transport failure emits only a low-sensitive failure fact. + +Copy `mcp-host.example.json` into the external MCP host's private +configuration, replace its launcher path, image digest, private root and +network, and map its `command`/`args`/`env` fields to the host's equivalent +stdio process adapter. `serve` runs Docker attached to stdin/stdout with a +read-only root filesystem, no capabilities, no-new-privileges, a fixed +non-root UID, bounded memory/CPU/PIDs and `--pull never`. + +Do not compose this directory into Edge/Standalone, `cluster-control`, the +Cluster AI Pod or any shared Kubernetes operations Kustomization. Small router +profiles continue to use the separately bounded Local MCP artifact only when +explicitly selected; otherwise they carry no MCP dependency at all. diff --git a/deploy/mcp/ql3-cluster-copilot/client-config.example.json b/deploy/mcp/ql3-cluster-copilot/client-config.example.json new file mode 100644 index 00000000..96f6bdaa --- /dev/null +++ b/deploy/mcp/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": "/var/run/secrets/qinglong3/copilot-mcp/ca.pem", + "requestTimeoutMs": 30000 +} diff --git a/deploy/mcp/ql3-cluster-copilot/docker-stdio.sh b/deploy/mcp/ql3-cluster-copilot/docker-stdio.sh new file mode 100755 index 00000000..f54bcc9d --- /dev/null +++ b/deploy/mcp/ql3-cluster-copilot/docker-stdio.sh @@ -0,0 +1,83 @@ +#!/bin/sh + +set -eu + +usage() { + printf '%s\n' 'Usage: docker-stdio.sh check|serve' >&2 + exit 64 +} + +fail() { + printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-copilot-mcp-launcher","event":"launch_failed"}' >&2 + exit 78 +} + +[ "$#" -eq 1 ] || usage +mode=$1 +case "$mode" in + check|serve) ;; + *) usage ;; +esac + +image=${QL3_COPILOT_MCP_IMAGE-} +private_root=${QL3_COPILOT_MCP_PRIVATE_ROOT-} +network=${QL3_COPILOT_MCP_NETWORK-} +resource_class=${QL3_COPILOT_MCP_RESOURCE_CLASS-compact} + +printf '%s' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}@sha256:[0-9a-f]{64}$' || fail +printf '%s' "$network" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$' || fail +case "$network" in + bridge|default|host|none) fail ;; +esac +case "$private_root" in + /*) ;; + *) fail ;; +esac +case "$private_root" in + *','*|*':'*) fail ;; +esac +[ -d "$private_root" ] || fail +canonical_root=$(CDPATH= cd -- "$private_root" 2>/dev/null && pwd -P) || fail +[ "$canonical_root" = "$private_root" ] || fail + +case "$resource_class" in + compact) + memory=192m + cpus=0.25 + pids=32 + concurrency_ceiling=1 + ;; + standard) + memory=512m + cpus=1 + pids=64 + concurrency_ceiling=4 + ;; + dense) + memory=1g + cpus=2 + pids=96 + concurrency_ceiling=16 + ;; + *) fail ;; +esac + +set -- docker run --rm -i --pull never --init --read-only \ + --network "$network" \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --user 10001:10001 \ + --pids-limit "$pids" \ + --memory "$memory" \ + --cpus "$cpus" \ + --mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-mcp,readonly" \ + "$image" \ + copilot-mcp + +if [ "$mode" = check ]; then + exec "$@" --check --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json \ + "--concurrency-ceiling=$concurrency_ceiling" +fi + +exec "$@" --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json \ + "--concurrency-ceiling=$concurrency_ceiling" diff --git a/deploy/mcp/ql3-cluster-copilot/mcp-config.example.json b/deploy/mcp/ql3-cluster-copilot/mcp-config.example.json new file mode 100644 index 00000000..90dfb2f9 --- /dev/null +++ b/deploy/mcp/ql3-cluster-copilot/mcp-config.example.json @@ -0,0 +1,6 @@ +{ + "schema": "qinglong/cluster-copilot-mcp-server@v1", + "clientConfigFile": "/var/run/secrets/qinglong3/copilot-mcp/client.json", + "credentialFile": "/var/run/secrets/qinglong3/copilot-mcp/credential", + "maxConcurrentRequests": 1 +} diff --git a/deploy/mcp/ql3-cluster-copilot/mcp-host.example.json b/deploy/mcp/ql3-cluster-copilot/mcp-host.example.json new file mode 100644 index 00000000..c5706208 --- /dev/null +++ b/deploy/mcp/ql3-cluster-copilot/mcp-host.example.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "qinglong-cluster-copilot": { + "command": "/absolute/path/to/docker-stdio.sh", + "args": ["serve"], + "env": { + "QL3_COPILOT_MCP_IMAGE": "ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "QL3_COPILOT_MCP_PRIVATE_ROOT": "/absolute/private/qinglong3-cluster-copilot", + "QL3_COPILOT_MCP_NETWORK": "qinglong3-copilot-egress", + "QL3_COPILOT_MCP_RESOURCE_CLASS": "compact" + } + } + } +} diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index caeea8ab..d7b47f56 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-326/ADR-0418(已接受):Cluster Copilot MCP 已补齐明确的外部 host 部署与资源边界。MCP 仍是 stdio 子进程,必须由支持 MCP 的外部 host 按 session 启动并持有 stdin/stdout;它不部署为 Kubernetes Deployment/Service,否则会形成没有消费者却长期持有 Project credential 的孤儿进程。新增 `deploy/mcp/ql3-cluster-copilot/` 提供 digest-pinned host 配置、owner-private client/MCP 配置示例和固定 Docker launcher;launcher 只允许显式命名网络与 `compact|standard|dense` 三档资源,分别限制为 192 MiB/0.25 CPU/32 PIDs/并发 1、512 MiB/1 CPU/64 PIDs/并发 4、1 GiB/2 CPU/96 PIDs/并发 16,并强制 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有 authority root,禁止 Docker socket、Kubernetes token、数据库 credential、host/default/bridge/none 网络和可写工作目录。统一产品入口新增第九个静态命令 `ql3-cluster-admin copilot-mcp`;`ql3-copilot-mcp --check` 会先复验私有 config/credential/CA,再用无认证、固定 `GET /readyz` 做低敏预检,并在启动前拒绝配置并发超过 host resource class ceiling,serve 路径仍保持无 listener、无 queue/retry/poller/cache。部署审计同时禁止任何 Kubernetes YAML 常驻该 MCP,并修正了一个真实发布缺陷:OCI layout 旧 fixture 仍声称 Admin 镜像入口是 recovery CLI,现已与真实 `product-cli/cli.js` entrypoint 对齐。workspace 仍为 18 package、无 single-source/shallow package;Cluster Admin 保持 116 个源码、115 个位于嵌套职责目录,Admin SBOM 保持 91 components/87 external/4 internal,Control 和全部 Local 闭包不变。专项发布审计 145/145、Cluster Admin 362 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,210 pass/2 条件 skip/0 fail,package/dependency/Edge import/Cluster deployment 审计零 finding。真实 arm64 Admin image `qinglong3-cluster-admin:d326-local` 为 344,423,357 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 9 个产品命令与新 entrypoint。14 档 Local artifact 全部逐档复验且与 D-325 完全一致:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes,证明 Cluster MCP host 部署没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership/read-only Copilot surface,或使用公开 release digest 补真实外部 host session 证据;均不得把 stdio MCP 改成常驻服务或扩大其 credential/网络 authority。 - D-325/ADR-0417(已接受):Cluster Copilot 现已提供独立、受限、可部署的 MCP stdio 产品面。实现没有扩展旧 2.x Web UI,也没有把 Cluster authority 塞入 Edge/Standalone 的 `@qinglong/local-mcp-server`;而是在既有 `@qinglong/cluster-admin` 的内聚 `copilot-mcp/` 目录新增 `ql3-copilot-mcp` 与 `./copilot-mcp` export,workspace 仍保持 18 package。四个静态 Tool 只接收 Project、source Run、diagnosis request、trace/mutation identity,并直接调用 D-324 的共享 TypeScript client;不启动 CLI 子进程、不写 command 临时文件、不监听网络、不直连数据库/application capability,也不允许调用者提供 URL、header、credential、Model/Provider、Artifact、usage/cost 或 Policy fence。owner-private 0600 配置只保存 client config/credential 路径和显式 `1..16` 并发上限;credential 每次 Tool call 都重新执行 canonical/private/TOCTOU 与 token 校验,rotation 下一次调用立即生效。满载即时返回 `copilot_mcp_busy`,没有隐藏 queue、retry、poller、timer、watcher 或 cache。所有结果使用 exact `qinglong/cluster-copilot-mcp-result@v1`,固定 `instructionPolicy=data_only_never_execute` 与 `actionAuthority=none`;只有 output Tool 标为 `potentially_sensitive`/`untrusted_model_output`,远端错误仅投影有界 status/code/request identity/Retry-After。真实 stdio + TLS 1.3 E2E 已覆盖 initialize、discovery、四次直接请求、Bearer credential 热轮换、无 client certificate、敏感输出标注与 graceful close;并发和未知字段均失败关闭。Cluster Admin 完整测试 361 pass/3 条件 skip,18-package clean build/test 退出 0,backend 1,207 pass/2 条件 skip/0 fail;package/dependency/Edge import/Cluster deployment 四项审计零 finding,Cluster Admin 为 116 个源码且 115 个位于嵌套职责目录。Cluster Admin 镜像精确加入已固定的 `@modelcontextprotocol/server@2.0.0`,SBOM 为 91 components/87 external/4 internal;Cluster Control 和全部 Local 闭包不变。14 档 Local artifact 全部通过,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes、315 files、56 modules,证明 Cluster MCP 没有进入低配路由设备;本 Gate 无 schema、migration、SQL、role、Pool、连接或部署拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership 或增加 MCP host 的明确部署清单/运维面,仍必须复用同一 API,不得回接 2.x controller/session 或扩大 credential authority。 - D-324/ADR-0416(已接受):Cluster Copilot failure diagnosis 已获得首个可直接部署的有界产品客户端。既有 `@qinglong/cluster-admin` 在内聚的 `copilot-client/` 目录提供共享 client 与 `ql3-copilot-client`,统一 `ql3-cluster-admin copilot` 静态委托同一 binary;没有为三个实现文件新增 workspace package。客户端只接受 owner-private 0600 的绝对 `--config`、`--command`、`--credential` 文件路径,Project API credential 固定为独立 `ql3c_` Bearer authority,禁止写入 argv 值、环境、stdin、command 或 operator context,也不复用管理面的 User JWT/mTLS client certificate。`diagnose|inspect|output|cancel` 四个 operation 只调用 D-321 至 D-323 的既有 API,TLS 固定 1.3、显式 CA/DNS、无连接复用/压缩/redirect/proxy/ambient CA;diagnose request identity、cancel mutation identity 和只读 transport identity 必须与唯一响应 `x-request-id` exact matching。成功响应重新执行 schema、target、状态机、digest、usage/cost 与 UTF-8 byte exact validation;只有调用者显式选择 `output` 才向 stdout 返回诊断文本,远端失败只投影 status、稳定 code、request identity 与有界 Retry-After。operator context 只能保存 Copilot config 路径,并新增离线 validate 与无认证固定 `GET /readyz` probe,不能保存 credential/command 或获得调用 authority。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Cluster Admin 从 109 增至 112 个源码,其中 111 个在嵌套职责目录,未新增生产依赖、schema、migration、SQL、role、Pool、连接、进程、timer、watcher、queue、cache、Pod、Service 或 Kubernetes 权限。Copilot/产品 CLI 定向 19/19、Cluster Admin 354 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,207 pass/2 条件 skip/0 fail;package/dependency/Edge import/Cluster deployment 四项审计零 finding,14 档 Local artifact 全部通过。默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes、315 files、56 modules,证明 Cluster-only client 没有进入低配路由设备闭包。本 Gate 没有数据库或部署拓扑变更,因此不重跑物理 HA,继续引用 D-323 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 与 SHA-256 `5dbcffb74a3181aabee66a8f68ecfa7a65e0491a6f2ba24e2bc903c83da9d766` 基线。下一独立 Gate 可让 UI/MCP 复用同一公开 API/contract,不能执行 CLI 子进程、直连 application capability/数据库或扩大 credential authority。 - D-323/ADR-0415(已接受):Cluster Copilot failure diagnosis 已补齐受围栏的 request-keyed 取消 mutation。`POST /api/v3/projects/{projectId}/runs/{runId}/copilot/failure-diagnoses/{requestId}/cancellation` 复用 exact `qinglong/run-cancellation@v1` body、`run.stop` 当前 Policy、durable audit 与通用 PostgreSQL Run cancellation transaction;调用方只能提供 `mutationId`,diagnosis Run、Event、reason、Provider 与终态均由服务端从 admission plan/receipt 和 durable authority 派生。pre-Model 取消立即与 Model start 通过 Run/Step/version fence 竞争并原子 terminalize;Model start 获胜时只返回 `model_in_flight` durable intent,不伪造 Provider abort、终态、usage 或 cost,真实 completion/finalization 仍可成为最终 winner。相同或不同 mutation 都不能追加第二个 intent/Event 或覆盖首次 reason/time。能力只注入默认关闭的 Cluster AI profile,复用既有 AI Pool、repository、Policy pipeline 与进程,不新增 package、schema、Pool、连接、timer、watcher、queue、cache、端口、Pod、Service 或 Kubernetes 权限,Edge/Standalone 与普通 Cluster Control 均无该 route。目录审计曾拒绝 `failure-diagnosis` 达到 12 个直属源码文件;最终删除仅做 re-export 的单文件 façade,让公开 package subpath 直接指向 `cancellation/service`,而不是再拆 package 或放宽阈值。workspace 保持 18 package,`singleSourcePackages=[]`、`shallowSourcePackages=[]`,AI 194 个源码中 193 个、Cluster Control 64 个源码中 62 个位于嵌套职责目录。AI 254 pass/3 条件 skip、Cluster Control 261 pass/2 条件 skip、18-package clean build/test 与 backend 1,207 pass/2 条件 skip/0 fail;四项架构审计和 14 档 Local artifact 全部通过。默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,Edge/Standalone AI application 为 4,493,043/4,493,175 bytes,证明 Cluster-only mutation 未进入低配闭包。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,覆盖 intent/Event/terminal receipt 同步复制、promotion 后 exact replay 与 in-flight 不伪造终态;报告 SHA-256 为 `5dbcffb74a3181aabee66a8f68ecfa7a65e0491a6f2ba24e2bc903c83da9d766`,离线审计零 finding。下一独立 Gate 可选择 CLI/UI/MCP 客户端或带 Provider abort acknowledgement 的取消语义,不得在没有 durable acknowledgement 时宣称外部调用已停止。 diff --git a/docs/adr/ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md b/docs/adr/ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md new file mode 100644 index 00000000..ad0e2dcb --- /dev/null +++ b/docs/adr/ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md @@ -0,0 +1,41 @@ +# ADR-0418:显式 Cluster Copilot MCP Host 部署与资源边界 + +- 状态:Accepted +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-326、Phase 2 + +## 背景 + +D-325 提供了独立 `ql3-copilot-mcp` stdio 进程,但只存在 binary 并不能证明用户可以安全部署它。若把 stdio server 直接包装成 Kubernetes Deployment/Service,Pod 没有拥有其 stdin/stdout 的 MCP host session,既不可达又长期携带 API credential;若让用户随意拼 `docker run`,则容易使用 mutable tag、默认网络、root、可写文件系统、无限资源或把 credential 写入环境。Cluster Admin 镜像的默认入口已经是统一产品 façade,但 OCI layout 审计仍错误期待旧 recovery binary,模拟证据不能约束真实镜像。 + +QingLong 的部署跨度还要求明确区分:Edge/Standalone 小设备不应安装 Cluster Admin/MCP 依赖;Cluster MCP host 本身也要支持资源受限的运维终端与较高并发的专用节点,而不能靠隐藏队列或无限容器配额吸收差异。 + +## 决策 + +1. `ql3-cluster-admin` 产品 façade 增加静态 `copilot-mcp` 命令,仍用当前 Node executable、same-image canonical target、`shell=false` 和 inherited stdio;不通过 shell、动态 package resolution 或 entrypoint override 启动 MCP。 +2. `ql3-copilot-mcp --check --config ...` 在不启动 MCP transport 的情况下完整验证 owner-private MCP/client/credential/CA authority,并只发一个不带认证的 `GET /readyz`。成功结果仅包含 transport、ready、配置/credential 有效性、并发上限和固定请求语义;不返回 endpoint、DNS、路径、credential、Project 或 Cluster identity。not-ready 退出 69,异常仍只输出低敏失败事实。 +3. CLI 接受可选 `--concurrency-ceiling=1..16`。配置的 `maxConcurrentRequests` 高于外部部署 ceiling 时在 listener、stdio server 和网络 probe 前失败关闭。该 ceiling 不改变 D-325 的即时 busy/no-queue 语义,只防止私有配置越过容器资源档位。 +4. 提供唯一受审 host-side Docker stdio launcher。它只接受 `check|serve` 和四个非 secret 环境值:immutable Admin image digest、canonical private projection root、专用 Docker network 名和 `compact|standard|dense` resource class。credential value 不得进入 argv、environment、host adapter 或 image。 +5. 三档固定为:compact `192 MiB/0.25 CPU/32 PID/concurrency 1`,standard `512 MiB/1 CPU/64 PID/concurrency 4`,dense `1 GiB/2 CPU/96 PID/concurrency 16`。launcher 固定 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有目录,不挂载 Docker socket、Kubernetes token、数据库 credential 或可写目录。 +6. launcher 拒绝 mutable/tag-only image、非 canonical/含分隔歧义的 private root、隐式或 `bridge|default|host|none` 网络和未知资源档。专用 Docker network 只提供命名隔离;生产 host firewall 仍必须把 egress 限制到 DNS 与 exact Cluster API destination。 +7. stdio MCP 由外部 MCP host 父进程启动,不新增 Kubernetes Deployment、Service、Ingress、RBAC、ServiceAccount、sidecar、health timer 或 restart controller。Cluster AI component 继续只拥有 server-side Copilot composition;二者不能合并。 +8. Admin image 继续独立发布;本 Gate 不再拆第 19 个 package 或复制 MCP runtime。镜像 metadata 改为同时描述 cluster operations 与 bounded stdio MCP,OCI layout 审计必须期待 Dockerfile 的真实 product façade entrypoint,而非历史 recovery binary。 +9. Edge/Standalone、Local MCP、Cluster Control、Cluster AI 和 shared Kubernetes operations 均不得引用 host launcher。路由设备默认制品与依赖闭包保持不变;需要本机 MCP 时仍只使用独立 `@qinglong/local-mcp-server` Profile。 + +## 不选择 + +- **把 stdio MCP 作为独立 Kubernetes Deployment/Service**:没有父 host session,网络 Service 也不能把 MCP stdio 变成 HTTP;会留下不可达的长期 credential Pod。 +- **在 Cluster Control/AI Pod 增加 MCP sidecar**:混淆 server-side Tool/Model authority 与 operator API credential,扩大常驻资源和故障域。 +- **允许任意 Docker flags 或 mutable tag**:无法证明镜像、身份、挂载、网络和资源边界。 +- **为 launcher 新建 workspace package**:部署 adapter 没有新的领域模型或 consumer closure,会重新制造过细包并突破 18-package hard cap。 +- **把 resource class 只写进文档**:私有 config 可静默扩大并发,容器内存上限无法成为可执行契约。 +- **在 MCP 进程增加轮询 health timer**:引入常驻网络负载和隐藏生命周期;一次性 check 与 host-owned restart 足够。 + +## 验收 + +1. 产品 catalog/help/delegation 覆盖八个 remote client 与 `copilot-mcp`,并证明 target 位于同一安装、stdio 原样继承、signal 可收敛。 +2. TLS 1.3 preflight 覆盖 config/credential validation、无 Authorization 的 exact `/readyz`、ready/not-ready/transport failure、并发 ceiling 和低敏输出。 +3. launcher 测试以假 Docker 捕获完整 argv,证明三档资源、immutable digest、named network、non-root/read-only/no-capability/no-new-privileges、只读 projection 和无 secret env;负例覆盖 tag、ambient network 与路径漂移。 +4. Cluster deployment audit 必须拒绝 launcher contract 漂移以及任何 Kubernetes YAML 中的 `ql3-copilot-mcp` 常驻资源;package/Edge import/Local artifact 门不得放宽。 +5. OCI layout fixture 与真实 Dockerfile 必须共同绑定 product façade entrypoint 和新 metadata;Admin image live gate 至少验证 `copilot`、`copilot-mcp` help 与全部既有命令。 +6. Cluster Admin、18-package clean build/test、backend、SBOM/OCI/部署审计和 14 档 Local artifact 全部通过后才允许 D-326 阶段提交。本 Gate 无 schema、migration、SQL、role、Pool 或 HA 拓扑变化,不重复数据库物理 HA。 diff --git a/docs/adr/README.md b/docs/adr/README.md index b97a12b5..176c4c33 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -421,6 +421,7 @@ | [ADR-0415](./ADR-0415-fenced-copilot-failure-diagnosis-cancellation.md) | 受围栏的 Copilot 故障诊断取消入口 | Accepted | | [ADR-0416](./ADR-0416-bounded-cluster-copilot-product-client.md) | 有界 Cluster Copilot 产品客户端 | Accepted | | [ADR-0417](./ADR-0417-bounded-cluster-copilot-mcp-stdio-surface.md) | 有界 Cluster Copilot MCP stdio 产品面 | Accepted | +| [ADR-0418](./ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md) | 显式 Cluster Copilot MCP Host 部署与资源边界 | Accepted | ## 规则 diff --git a/packages/ql3-cluster-admin/README.md b/packages/ql3-cluster-admin/README.md index 6695251e..d278c029 100644 --- a/packages/ql3-cluster-admin/README.md +++ b/packages/ql3-cluster-admin/README.md @@ -1,8 +1,10 @@ # `@qinglong/cluster-admin` -This private QingLong 3.0 package is a short-lived cluster administration -authority. It is intentionally separate from the resident `cluster-control` -artifact and requires a distinct PostgreSQL role. +This private QingLong 3.0 package owns explicit cluster operations and the +bounded Cluster Copilot MCP product surface. Database/Kubernetes administration +remains short-lived and requires distinct purpose-bound authority; the MCP +subpath has only the remote API client, opens no database or Kubernetes +authority, and is intentionally separate from resident `cluster-control`. The admin role can append Identity/API Credential mutations and their security audit in one serializable transaction, and can perform bounded read-only audit diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index d6e4b60d..df2af9ef 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 short-lived cluster administration authority", + "description": "QingLong 3.0 cluster operations and bounded Copilot MCP surface", "license": "Apache-2.0", "engines": { "node": ">=24.18.0 <25" diff --git a/packages/ql3-cluster-admin/src/copilot-mcp/cli.ts b/packages/ql3-cluster-admin/src/copilot-mcp/cli.ts index 701c83ba..da474f00 100644 --- a/packages/ql3-cluster-admin/src/copilot-mcp/cli.ts +++ b/packages/ql3-cluster-admin/src/copilot-mcp/cli.ts @@ -2,14 +2,46 @@ import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { probeClusterCopilotClientReadiness } from '../copilot-client/client'; import { readClusterCopilotMcpServerConfig } from './config'; import { createQingLongClusterCopilotMcpServer } from './server'; -const USAGE = 'Usage: ql3-copilot-mcp --config /absolute/private-config.json'; +const USAGE = [ + 'Usage: ql3-copilot-mcp --config /absolute/private-config.json [--concurrency-ceiling=1..16]', + ' ql3-copilot-mcp --check --config /absolute/private-config.json [--concurrency-ceiling=1..16]', +].join('\n'); -function configArgument(argv: readonly string[]): string | null { - if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null; - return argv[1]; +interface ClusterCopilotMcpCliArguments { + readonly check: boolean; + readonly configFile: string; + readonly concurrencyCeiling: number; +} + +function configArgument( + argv: readonly string[], +): Readonly | null { + const check = argv[0] === '--check'; + const offset = check ? 1 : 0; + if ( + (argv.length !== offset + 2 && argv.length !== offset + 3) || + argv[offset] !== '--config' || + !argv[offset + 1] + ) { + return null; + } + let concurrencyCeiling = 16; + if (argv.length === offset + 3) { + const match = /^--concurrency-ceiling=([1-9]|1[0-6])$/u.exec( + argv[offset + 2] ?? '', + ); + if (!match) return null; + concurrencyCeiling = Number(match[1]); + } + return Object.freeze({ + check, + configFile: argv[offset + 1]!, + concurrencyCeiling, + }); } function fact(event: 'process_failed' | 'transport_error'): string { @@ -26,8 +58,8 @@ async function main(argv: readonly string[]): Promise { process.stdout.write(`${USAGE}\n`); return; } - const configFile = configArgument(argv); - if (configFile === null) { + const command = configArgument(argv); + if (command === null) { process.stderr.write( `${JSON.stringify({ code: 'QL3_CLUSTER_COPILOT_MCP_CLI_USAGE_INVALID', @@ -46,7 +78,33 @@ async function main(argv: readonly string[]): Promise { }; try { - const config = readClusterCopilotMcpServerConfig(configFile); + const config = readClusterCopilotMcpServerConfig(command.configFile); + if (config.maxConcurrentRequests > command.concurrencyCeiling) { + throw new TypeError('Cluster Copilot MCP concurrency ceiling exceeded'); + } + if (command.check) { + const readiness = await probeClusterCopilotClientReadiness( + config.clientConfigFile, + ); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-mcp', + event: 'preflight_checked', + transport: readiness.transport, + ready: readiness.ready, + configuration: 'valid', + credential: 'valid', + maxConcurrentRequests: config.maxConcurrentRequests, + concurrencyCeiling: command.concurrencyCeiling, + requestMethod: 'GET', + requestPath: '/readyz', + mutation: false, + })}\n`, + ); + if (!readiness.ready) process.exitCode = 69; + return; + } handle = serveStdio( () => createQingLongClusterCopilotMcpServer({ config }), { diff --git a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts index c8b8fca8..ffb4cb39 100644 --- a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts +++ b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts @@ -40,6 +40,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc target: 'copilot-client/cli.js', description: 'diagnose, inspect, read or cancel Runs through the API', }), + Object.freeze({ + name: 'copilot-mcp', + binary: 'ql3-copilot-mcp', + target: 'copilot-mcp/cli.js', + description: 'serve the bounded Cluster Copilot MCP over stdio', + }), Object.freeze({ name: 'package', binary: 'ql3-plugin-package-client', @@ -177,7 +183,7 @@ export function qingLong3ClusterProductHelp(): string { return [ 'Usage: ql3-cluster-admin [arguments]', '', - 'Remote client commands:', + 'Cluster product commands:', commands, '', 'Local operator commands:', @@ -185,7 +191,8 @@ export function qingLong3ClusterProductHelp(): string { ' context probe --context=/absolute/operator-context.json', '', 'Use `ql3-cluster-admin --help` for command-specific usage.', - 'Use `--context=/absolute/operator-context.json` to inject only stable client paths.', + '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.', '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/copilotMcpStdio.test.cjs b/packages/ql3-cluster-admin/test/copilotMcpStdio.test.cjs index cc099c60..5312d65e 100644 --- a/packages/ql3-cluster-admin/test/copilotMcpStdio.test.cjs +++ b/packages/ql3-cluster-admin/test/copilotMcpStdio.test.cjs @@ -44,7 +44,7 @@ function jsonResponse(response, statusCode, requestId, body) { response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(bytes.length), - 'x-request-id': requestId, + ...(requestId === undefined ? {} : { 'x-request-id': requestId }), }); response.end(bytes); } @@ -124,6 +124,7 @@ async function fixture(t) { ); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const requests = []; + const readiness = { value: 'ready' }; const server = createServer( { key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')), @@ -144,6 +145,15 @@ async function fixture(t) { peerCertificate: request.socket.getPeerCertificate(), body: chunks.length === 0 ? null : JSON.parse(Buffer.concat(chunks)), }); + if (request.url === '/readyz') { + jsonResponse( + response, + readiness.value === 'ready' ? 200 : 503, + undefined, + { status: readiness.value }, + ); + return; + } jsonResponse( response, request.method === 'POST' && !request.url.endsWith('/cancellation') ? 201 : 200, @@ -157,12 +167,17 @@ async function fixture(t) { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); - t.after( - () => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }), - ); + let closed = false; + const close = () => + new Promise((resolve, reject) => { + if (closed) { + resolve(); + return; + } + closed = true; + server.close((error) => (error ? reject(error) : resolve())); + }); + t.after(close); const caFile = privateFile( directory, 'ca.pem', @@ -190,7 +205,7 @@ async function fixture(t) { maxConcurrentRequests: 2, }), ); - return { requests, credentialFile, serverConfigFile }; + return { requests, readiness, close, credentialFile, serverConfigFile }; } function startClient(t, configFile) { @@ -271,7 +286,10 @@ function runCli(args) { } test('stdio CLI exposes deterministic help and low-sensitive startup failures', async () => { - const usage = 'Usage: ql3-copilot-mcp --config /absolute/private-config.json'; + const usage = [ + 'Usage: ql3-copilot-mcp --config /absolute/private-config.json [--concurrency-ceiling=1..16]', + ' ql3-copilot-mcp --check --config /absolute/private-config.json [--concurrency-ceiling=1..16]', + ].join('\n'); assert.deepEqual(await runCli(['--help']), { status: 0, signal: null, @@ -298,6 +316,87 @@ test('stdio CLI exposes deterministic help and low-sensitive startup failures', assert.doesNotMatch(failed.stderr, /secret-config-name/); }); +test('preflight validates mounted authority and probes readiness without authentication', async (t) => { + const value = await fixture(t); + const checked = await runCli([ + '--check', + '--config', + value.serverConfigFile, + '--concurrency-ceiling=4', + ]); + assert.equal(checked.status, 0); + assert.equal(checked.signal, null); + assert.equal(checked.stderr, ''); + assert.deepEqual(JSON.parse(checked.stdout), { + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-mcp', + event: 'preflight_checked', + transport: 'https', + ready: true, + configuration: 'valid', + credential: 'valid', + maxConcurrentRequests: 2, + concurrencyCeiling: 4, + requestMethod: 'GET', + requestPath: '/readyz', + mutation: false, + }); + assert.equal(value.requests.length, 1); + assert.deepEqual(value.requests[0], { + method: 'GET', + path: '/readyz', + authorization: undefined, + requestId: undefined, + tls: 'TLSv1.3', + peerCertificate: {}, + body: null, + }); + + value.readiness.value = 'not_ready'; + const notReady = await runCli([ + '--check', + '--config', + value.serverConfigFile, + '--concurrency-ceiling=4', + ]); + assert.equal(notReady.status, 69); + assert.equal(notReady.stderr, ''); + assert.equal(JSON.parse(notReady.stdout).ready, false); + assert.equal(value.requests.length, 2); + + await value.close(); + const unavailable = await runCli([ + '--check', + '--config', + value.serverConfigFile, + '--concurrency-ceiling=4', + ]); + assert.equal(unavailable.status, 1); + assert.equal(unavailable.stdout, ''); + assert.deepEqual(JSON.parse(unavailable.stderr), { + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-mcp', + level: 'error', + event: 'process_failed', + }); + + const overCeiling = await runCli([ + '--check', + '--config', + value.serverConfigFile, + '--concurrency-ceiling=1', + ]); + assert.equal(overCeiling.status, 1); + assert.equal(overCeiling.stdout, ''); + assert.deepEqual(JSON.parse(overCeiling.stderr), { + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-mcp', + level: 'error', + event: 'process_failed', + }); + assert.equal(value.requests.length, 2); +}); + test('stdio MCP uses direct TLS client, rotates credentials and labels untrusted output', async (t) => { const value = await fixture(t); const connected = startClient(t, value.serverConfigFile); diff --git a/packages/ql3-cluster-admin/test/productCli.test.cjs b/packages/ql3-cluster-admin/test/productCli.test.cjs index 7c174ba8..d0e361c6 100644 --- a/packages/ql3-cluster-admin/test/productCli.test.cjs +++ b/packages/ql3-cluster-admin/test/productCli.test.cjs @@ -331,9 +331,9 @@ function validContextFixture(t) { }; } -test('catalog exposes only reviewed remote clients from the same package', () => { +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, 8); + assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 9); assert.equal( new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size, QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, @@ -349,7 +349,11 @@ test('catalog exposes only reviewed remote clients from the same package', () => fs.lstatSync(path.join(packageRoot, 'dist', command.target)).isFile(), true, ); - assert.equal(command.binary.includes('-client'), true); + assert.equal( + command.binary.includes('-client') || + command.binary === 'ql3-copilot-mcp', + true, + ); } for (const forbidden of [ 'ql3-cluster-migrate', @@ -375,6 +379,7 @@ test('help and version are bounded installation-derived product facts', () => { assert.match(help, /^Usage: ql3-cluster-admin \[arguments\]/); 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, /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 e806cf66..78e3acbc 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -9,6 +9,14 @@ const ENTRYPOINT = [ '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js', ]; const COMMANDS = Object.freeze([ + Object.freeze({ + name: 'copilot', + usage: 'Usage: ql3-copilot-client ', + }), + Object.freeze({ + name: 'copilot-mcp', + usage: 'Usage: ql3-copilot-mcp --config ', + }), Object.freeze({ name: 'package', usage: 'Usage: ql3-plugin-package-client ', @@ -125,7 +133,9 @@ const validated = spawnSync(process.execPath, [facade, 'context', 'validate', '- let validationFact; try { validationFact = JSON.parse(validated.stdout); } catch { process.exit(25); } if (validated.status !== 0 || validated.stderr !== '' || validationFact.event !== 'context_valid' || validationFact.commandCount !== 1 || validationFact.networkAccess !== false || validationFact.mutation !== false || JSON.stringify(validationFact.commands) !== JSON.stringify([{ name: 'package', transport: 'https', clientCertificate: 'forbidden' }]) || validated.stdout.includes('/tmp/') || validated.stdout.includes('manager.example.test')) process.exit(26); -const readinessServer = spawn(process.execPath, ['-e', ${JSON.stringify(readinessServerSource)}], { stdio: 'ignore' }); +const readinessServer = spawn(process.execPath, ['-e', ${JSON.stringify( + readinessServerSource, + )}], { stdio: 'ignore' }); const waitArray = new Int32Array(new SharedArrayBuffer(4)); for (let attempt = 0; attempt < 200 && !existsSync('/tmp/readiness-port'); attempt += 1) Atomics.wait(waitArray, 0, 0, 10); if (!existsSync('/tmp/readiness-port')) process.exit(27); diff --git a/scripts/ql3-cluster-deployment-audit.cjs b/scripts/ql3-cluster-deployment-audit.cjs index a27db50b..d45df8a7 100644 --- a/scripts/ql3-cluster-deployment-audit.cjs +++ b/scripts/ql3-cluster-deployment-audit.cjs @@ -131,6 +131,105 @@ function assertClusterAdminImageCommands(readFile, root, findings) { return references; } +function assertClusterCopilotMcpHostDeployment(readFile, root, findings) { + const directory = path.join(root, 'deploy/mcp/ql3-cluster-copilot'); + const launcher = readFile(path.join(directory, 'docker-stdio.sh'), 'utf8'); + const mcpConfig = readJson( + readFile, + path.join(directory, 'mcp-config.example.json'), + ); + const clientConfig = readJson( + readFile, + path.join(directory, 'client-config.example.json'), + ); + const hostConfig = readJson( + readFile, + path.join(directory, 'mcp-host.example.json'), + ); + const host = hostConfig?.mcpServers?.['qinglong-cluster-copilot']; + const expectedHostEnvironment = { + QL3_COPILOT_MCP_IMAGE: + 'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64), + QL3_COPILOT_MCP_PRIVATE_ROOT: '/absolute/private/qinglong3-cluster-copilot', + QL3_COPILOT_MCP_NETWORK: 'qinglong3-copilot-egress', + QL3_COPILOT_MCP_RESOURCE_CLASS: 'compact', + }; + const requiredLauncherFragments = [ + 'docker run --rm -i --pull never --init --read-only', + '--network "$network"', + '--cap-drop ALL', + '--security-opt no-new-privileges', + '--user 10001:10001', + '--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-mcp,readonly"', + 'copilot-mcp', + '--check --config /var/run/secrets/qinglong3/copilot-mcp/mcp.json', + '--config /var/run/secrets/qinglong3/copilot-mcp/mcp.json', + 'compact)', + 'memory=192m', + 'concurrency_ceiling=1', + 'standard)', + 'memory=512m', + 'concurrency_ceiling=4', + 'dense)', + 'memory=1g', + 'concurrency_ceiling=16', + ]; + if ( + !launcher.startsWith('#!/bin/sh\n\nset -eu\n') || + requiredLauncherFragments.some( + (fragment) => !launcher.includes(fragment), + ) || + ['--privileged', '--network host', '/var/run/docker.sock'].some( + (fragment) => launcher.includes(fragment), + ) || + JSON.stringify(mcpConfig) !== + JSON.stringify({ + schema: 'qinglong/cluster-copilot-mcp-server@v1', + clientConfigFile: '/var/run/secrets/qinglong3/copilot-mcp/client.json', + credentialFile: '/var/run/secrets/qinglong3/copilot-mcp/credential', + maxConcurrentRequests: 1, + }) || + JSON.stringify(clientConfig) !== + JSON.stringify({ + schema: 'qinglong/cluster-copilot-client-config@v1', + endpoint: 'https://replace-cluster-api.example.com:5800/', + servername: 'replace-cluster-api.example.com', + caFile: '/var/run/secrets/qinglong3/copilot-mcp/ca.pem', + requestTimeoutMs: 30_000, + }) || + host?.command !== '/absolute/path/to/docker-stdio.sh' || + JSON.stringify(host?.args) !== JSON.stringify(['serve']) || + JSON.stringify(host?.env) !== JSON.stringify(expectedHostEnvironment) || + JSON.stringify(Object.keys(hostConfig?.mcpServers ?? {})) !== + JSON.stringify(['qinglong-cluster-copilot']) || + JSON.stringify(Object.keys(host ?? {}).sort()) !== + JSON.stringify(['args', 'command', 'env']) + ) { + findings.push( + finding( + 'QL3_CLUSTER_COPILOT_MCP_HOST_CONTRACT_DRIFT', + 'Cluster Copilot MCP must remain an explicit digest-pinned, non-root, read-only external stdio host process with bounded resource classes', + ), + ); + } + for (const filePath of kubernetesYamlFiles( + path.join(root, 'deploy/kubernetes/ql3-cluster'), + )) { + if (readFile(filePath, 'utf8').includes('ql3-copilot-mcp')) { + findings.push( + finding( + 'QL3_CLUSTER_COPILOT_MCP_KUBERNETES_RESIDENT', + `${path.relative( + root, + filePath, + )} must not deploy a parentless stdio MCP process`, + ), + ); + } + } + return 'external-host-stdio'; +} + function namedResource(resources, kind, name) { return resources.find( (resource) => resource?.kind === kind && resource?.metadata?.name === name, @@ -5465,6 +5564,7 @@ function auditClusterDeployment(options = {}) { const readFile = options.readFile ?? fs.readFileSync; const findings = []; let clusterAdminImageReferences = 0; + let clusterCopilotMcpHost = 'unavailable'; try { assertExactExternalClosure(readFile, root, findings); assertDockerfile(readFile, root, findings); @@ -5474,6 +5574,11 @@ function auditClusterDeployment(options = {}) { root, findings, ); + clusterCopilotMcpHost = assertClusterCopilotMcpHostDeployment( + readFile, + root, + findings, + ); assertClusterAiComponent(readFile, root, findings); assertPluginPackageManagementDeployment(readFile, root, findings); assertWorkerCredentialManagementDeployment(readFile, root, findings); @@ -5526,6 +5631,7 @@ function auditClusterDeployment(options = {}) { promptOutputKeyRotation: 'caller-driven-staged-material', clusterAi: 'optional-projected-authority', clusterAiPromptOutput: 'optional-read-only-projected-keyring', + clusterCopilotMcpHost, imageReleasePins: 'independent-fail-closed-digests', clusterAdminImageReferences, findings: Object.freeze(findings), diff --git a/scripts/ql3-cluster-oci-layout-audit.cjs b/scripts/ql3-cluster-oci-layout-audit.cjs index dd5270af..2a830ee1 100644 --- a/scripts/ql3-cluster-oci-layout-audit.cjs +++ b/scripts/ql3-cluster-oci-layout-audit.cjs @@ -232,7 +232,7 @@ function expectedImageConfig(architecture, revision, image) { ? isControlAi ? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js' : '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/cli.js' - : '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js', + : '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js', ], WorkingDir: '/opt/qinglong', Labels: { @@ -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 short-lived cluster administration jobs', + : 'QingLong 3.0 cluster operations and bounded stdio MCP', '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/ql3ClusterCopilotMcpLauncher.test.cjs b/test/back/ql3ClusterCopilotMcpLauncher.test.cjs new file mode 100644 index 00000000..84e0b503 --- /dev/null +++ b/test/back/ql3ClusterCopilotMcpLauncher.test.cjs @@ -0,0 +1,129 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const ROOT = path.resolve(__dirname, '../..'); +const launcher = path.join( + ROOT, + 'deploy/mcp/ql3-cluster-copilot/docker-stdio.sh', +); +const image = `registry.example/qinglong3-cluster-admin@sha256:${'a'.repeat( + 64, +)}`; + +function fixture(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-mcp-launcher-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const privateRoot = path.join(directory, 'private'); + const bin = path.join(directory, 'bin'); + fs.mkdirSync(privateRoot, { mode: 0o700 }); + fs.mkdirSync(bin, { mode: 0o700 }); + const capture = path.join(directory, 'docker-args'); + const docker = path.join(bin, 'docker'); + fs.writeFileSync( + docker, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$QL3_TEST_DOCKER_ARGS"\n', + { mode: 0o700 }, + ); + return { + privateRoot: fs.realpathSync(privateRoot), + capture, + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + QL3_TEST_DOCKER_ARGS: capture, + QL3_COPILOT_MCP_IMAGE: image, + QL3_COPILOT_MCP_PRIVATE_ROOT: fs.realpathSync(privateRoot), + QL3_COPILOT_MCP_NETWORK: 'qinglong3-copilot-egress', + QL3_COPILOT_MCP_RESOURCE_CLASS: 'compact', + }, + }; +} + +function invoke(args, env) { + return spawnSync(launcher, args, { + cwd: ROOT, + env, + encoding: 'utf8', + }); +} + +test('launches exact compact preflight with no ambient authority widening', (t) => { + assert.equal(fs.statSync(launcher).mode & 0o777, 0o755); + const value = fixture(t); + const result = invoke(['check'], value.env); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); + assert.deepEqual( + fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n'), + [ + 'run', + '--rm', + '-i', + '--pull', + 'never', + '--init', + '--read-only', + '--network', + 'qinglong3-copilot-egress', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '10001:10001', + '--pids-limit', + '32', + '--memory', + '192m', + '--cpus', + '0.25', + '--mount', + `type=bind,src=${value.privateRoot},dst=/var/run/secrets/qinglong3/copilot-mcp,readonly`, + image, + 'copilot-mcp', + '--check', + '--config', + '/var/run/secrets/qinglong3/copilot-mcp/mcp.json', + '--concurrency-ceiling=1', + ], + ); +}); + +test('binds standard serve resources and rejects tags or ambient networks', (t) => { + const value = fixture(t); + const served = invoke(['serve'], { + ...value.env, + QL3_COPILOT_MCP_RESOURCE_CLASS: 'standard', + }); + assert.equal(served.status, 0, served.stderr); + const args = fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n'); + assert.equal(args.includes('--check'), false); + assert.equal(args[args.indexOf('--memory') + 1], '512m'); + assert.equal(args[args.indexOf('--cpus') + 1], '1'); + assert.equal(args[args.indexOf('--pids-limit') + 1], '64'); + assert.equal(args.at(-1), '--concurrency-ceiling=4'); + + fs.rmSync(value.capture); + for (const environment of [ + { ...value.env, QL3_COPILOT_MCP_IMAGE: 'registry.example/qinglong:latest' }, + { ...value.env, QL3_COPILOT_MCP_NETWORK: 'host' }, + ]) { + const rejected = invoke(['serve'], environment); + assert.equal(rejected.status, 78); + assert.equal(rejected.stdout, ''); + assert.deepEqual(JSON.parse(rejected.stderr), { + schemaVersion: 1, + component: 'qinglong3-cluster-copilot-mcp-launcher', + event: 'launch_failed', + }); + assert.equal(fs.existsSync(value.capture), false); + assert.doesNotMatch(rejected.stderr, /registry|private|host/); + } +}); diff --git a/test/back/ql3ClusterDeploymentAudit.test.cjs b/test/back/ql3ClusterDeploymentAudit.test.cjs index a1555cfc..a62671e2 100644 --- a/test/back/ql3ClusterDeploymentAudit.test.cjs +++ b/test/back/ql3ClusterDeploymentAudit.test.cjs @@ -28,6 +28,7 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () => report.clusterAiPromptOutput, 'optional-read-only-projected-keyring', ); + assert.equal(report.clusterCopilotMcpHost, 'external-host-stdio'); assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material'); assert.equal(report.clusterAdminImageReferences, 24); assert.deepEqual(report.workspacePackages, [ @@ -42,6 +43,38 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () => ]); }); +test('keeps Cluster Copilot MCP external, digest-pinned and resource-bounded', () => { + const widened = auditClusterDeployment({ + root: ROOT, + readFile: intercept( + 'deploy/mcp/ql3-cluster-copilot/docker-stdio.sh', + (source) => source.replace('--network "$network"', '--network host'), + ), + }); + assert.equal(widened.compatible, false); + assert.equal( + widened.findings.some( + ({ code }) => code === 'QL3_CLUSTER_COPILOT_MCP_HOST_CONTRACT_DRIFT', + ), + true, + ); + + const resident = auditClusterDeployment({ + root: ROOT, + readFile: intercept( + 'deploy/kubernetes/ql3-cluster/base/deployment.yaml', + (source) => `${source}\n# ql3-copilot-mcp\n`, + ), + }); + assert.equal(resident.compatible, false); + assert.equal( + resident.findings.some( + ({ code }) => code === 'QL3_CLUSTER_COPILOT_MCP_KUBERNETES_RESIDENT', + ), + true, + ); +}); + test('requires every Cluster Admin Kubernetes workload to override the image command', () => { const report = auditClusterDeployment({ root: ROOT, @@ -69,7 +102,8 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () => root: ROOT, readFile: intercept( 'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/role.yaml', - (source) => source.replace(' - get\n', ' - get\n - list\n'), + (source) => + source.replace(' - get\n', ' - get\n - list\n'), ), }); assert.equal(widenedRole.compatible, false); @@ -84,7 +118,8 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () => root: ROOT, readFile: intercept( 'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/validating-admission-policy.yaml', - (source) => source.replace('failurePolicy: Fail', 'failurePolicy: Ignore'), + (source) => + source.replace('failurePolicy: Fail', 'failurePolicy: Ignore'), ), }); assert.equal(ignoredAdmissionFailure.compatible, false); @@ -99,10 +134,11 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () => root: ROOT, readFile: intercept( 'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/secret-action-service-account.yaml', - (source) => source.replace( - 'automountServiceAccountToken: false', - 'automountServiceAccountToken: true', - ), + (source) => + source.replace( + 'automountServiceAccountToken: false', + 'automountServiceAccountToken: true', + ), ), }); assert.equal(actionToken.compatible, false); @@ -166,6 +202,7 @@ test('ships a path-only Cluster operator context example without durable authori assert.deepEqual(Object.keys(example.commands).sort(), [ 'approval', 'automation', + 'copilot', 'model-credential', 'package', 'package-kubernetes', diff --git a/test/back/ql3ClusterOciLayoutAudit.test.cjs b/test/back/ql3ClusterOciLayoutAudit.test.cjs index 05431380..652c055a 100644 --- a/test/back/ql3ClusterOciLayoutAudit.test.cjs +++ b/test/back/ql3ClusterOciLayoutAudit.test.cjs @@ -114,7 +114,7 @@ function createFixture(t, options = {}) { ? isControlAi ? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js' : '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/cli.js' - : '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/recovery/pluginPackageRecoveryCli.js', + : '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js', ], WorkingDir: '/opt/qinglong', Labels: { @@ -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 short-lived cluster administration jobs', + : 'QingLong 3.0 cluster operations and bounded stdio MCP', 'org.opencontainers.image.licenses': 'Apache-2.0', 'org.opencontainers.image.revision': revision, 'org.opencontainers.image.source':