mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): harden copilot mcp host deployment
This commit is contained in:
@@ -80,7 +80,7 @@ FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd3
|
|||||||
ARG SOURCE_REVISION=uncommitted
|
ARG SOURCE_REVISION=uncommitted
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \
|
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.source="https://github.com/whyour/qinglong" \
|
||||||
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
||||||
org.opencontainers.image.licenses="Apache-2.0"
|
org.opencontainers.image.licenses="Apache-2.0"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@qinglong/cluster-admin-image-dependencies",
|
"name": "@qinglong/cluster-admin-image-dependencies",
|
||||||
"version": "3.0.0-alpha.0",
|
"version": "3.0.0-alpha.0",
|
||||||
"private": true,
|
"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",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.18.0 <25"
|
"node": ">=24.18.0 <25"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@qinglong/cluster-admin-image-dependencies",
|
"name": "@qinglong/cluster-admin-image-dependencies",
|
||||||
"version": "3.0.0-alpha.0",
|
"version": "3.0.0-alpha.0",
|
||||||
"private": true,
|
"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",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.18.0 <25"
|
"node": ">=24.18.0 <25"
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ docker build \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The admin image has its own builder and production locks. Its five production
|
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`,
|
roots resolve to 87 external packages and it adds only `runtime-core`, `ai`,
|
||||||
`cluster-postgres` and `cluster-admin`. Kubernetes client code and the admin
|
`cluster-postgres` and `cluster-admin`. Kubernetes client code and admin
|
||||||
database role therefore exist only in an ephemeral Job image, never in
|
database roles remain outside `cluster-control`; explicit operation commands
|
||||||
`cluster-control`.
|
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
|
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
|
`package-kubernetes`, `worker-credential`, `approval`, `run`, `automation` and
|
||||||
`model-credential`. It delegates with the current Node executable, an exact
|
`model-credential`, plus the bounded `copilot-mcp` stdio process. It delegates
|
||||||
same-image target and `shell=false`; arguments remain opaque. Server
|
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
|
`*-manage` processes, migration, recovery, executors, Prompt output key custody
|
||||||
and garbage collection remain separate explicit binaries and Kubernetes
|
and garbage collection remain separate explicit binaries and Kubernetes
|
||||||
operations. Existing Jobs continue to name their exact binary and do not
|
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
|
or ambient Kubernetes context discovery. Existing explicit `--config` calls
|
||||||
remain supported when `--context` is absent.
|
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:
|
Validate the complete local operator context before a maintenance window:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -78,7 +88,7 @@ ql3-cluster-admin context validate \
|
|||||||
|
|
||||||
This command reuses the production client parsers to validate every selected
|
This command reuses the production client parsers to validate every selected
|
||||||
endpoint shape, CA, matching client certificate/private key and the bounded
|
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
|
same installation. It does not read a command or assertion, open a network
|
||||||
connection, query Kubernetes or mutate the cluster. Its JSON summary contains
|
connection, query Kubernetes or mutate the cluster. Its JSON summary contains
|
||||||
only command names and reviewed transport/authentication classes; paths,
|
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
|
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
|
closure legitimately contains the upstream runtime dependency
|
||||||
`@types/js-yaml` from `@kubernetes/client-node`; the exact production lock, not
|
`@types/js-yaml` from `@kubernetes/client-node`; the exact production lock, not
|
||||||
the package-name prefix, is authoritative. TypeScript, root devDependencies and
|
the package-name prefix, is authoritative. TypeScript, root devDependencies and
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"commands": {
|
"commands": {
|
||||||
|
"copilot": {
|
||||||
|
"configFile": "/secure/qinglong3/copilot-client.json"
|
||||||
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"configFile": "/secure/qinglong3/plugin-package-client.json"
|
"configFile": "/secure/qinglong3/plugin-package-client.json"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+83
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(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-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-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 时宣称外部调用已停止。
|
- 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 时宣称外部调用已停止。
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -421,6 +421,7 @@
|
|||||||
| [ADR-0415](./ADR-0415-fenced-copilot-failure-diagnosis-cancellation.md) | 受围栏的 Copilot 故障诊断取消入口 | Accepted |
|
| [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-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-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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# `@qinglong/cluster-admin`
|
# `@qinglong/cluster-admin`
|
||||||
|
|
||||||
This private QingLong 3.0 package is a short-lived cluster administration
|
This private QingLong 3.0 package owns explicit cluster operations and the
|
||||||
authority. It is intentionally separate from the resident `cluster-control`
|
bounded Cluster Copilot MCP product surface. Database/Kubernetes administration
|
||||||
artifact and requires a distinct PostgreSQL role.
|
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
|
The admin role can append Identity/API Credential mutations and their security
|
||||||
audit in one serializable transaction, and can perform bounded read-only audit
|
audit in one serializable transaction, and can perform bounded read-only audit
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@qinglong/cluster-admin",
|
"name": "@qinglong/cluster-admin",
|
||||||
"version": "3.0.0-alpha.0",
|
"version": "3.0.0-alpha.0",
|
||||||
"private": true,
|
"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",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.18.0 <25"
|
"node": ">=24.18.0 <25"
|
||||||
|
|||||||
@@ -2,14 +2,46 @@
|
|||||||
|
|
||||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||||
|
|
||||||
|
import { probeClusterCopilotClientReadiness } from '../copilot-client/client';
|
||||||
import { readClusterCopilotMcpServerConfig } from './config';
|
import { readClusterCopilotMcpServerConfig } from './config';
|
||||||
import { createQingLongClusterCopilotMcpServer } from './server';
|
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 {
|
interface ClusterCopilotMcpCliArguments {
|
||||||
if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null;
|
readonly check: boolean;
|
||||||
return argv[1];
|
readonly configFile: string;
|
||||||
|
readonly concurrencyCeiling: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configArgument(
|
||||||
|
argv: readonly string[],
|
||||||
|
): Readonly<ClusterCopilotMcpCliArguments> | 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 {
|
function fact(event: 'process_failed' | 'transport_error'): string {
|
||||||
@@ -26,8 +58,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
process.stdout.write(`${USAGE}\n`);
|
process.stdout.write(`${USAGE}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const configFile = configArgument(argv);
|
const command = configArgument(argv);
|
||||||
if (configFile === null) {
|
if (command === null) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`${JSON.stringify({
|
`${JSON.stringify({
|
||||||
code: 'QL3_CLUSTER_COPILOT_MCP_CLI_USAGE_INVALID',
|
code: 'QL3_CLUSTER_COPILOT_MCP_CLI_USAGE_INVALID',
|
||||||
@@ -46,7 +78,33 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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(
|
handle = serveStdio(
|
||||||
() => createQingLongClusterCopilotMcpServer({ config }),
|
() => createQingLongClusterCopilotMcpServer({ config }),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
|||||||
target: 'copilot-client/cli.js',
|
target: 'copilot-client/cli.js',
|
||||||
description: 'diagnose, inspect, read or cancel Runs through the API',
|
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({
|
Object.freeze({
|
||||||
name: 'package',
|
name: 'package',
|
||||||
binary: 'ql3-plugin-package-client',
|
binary: 'ql3-plugin-package-client',
|
||||||
@@ -177,7 +183,7 @@ export function qingLong3ClusterProductHelp(): string {
|
|||||||
return [
|
return [
|
||||||
'Usage: ql3-cluster-admin <command> [arguments]',
|
'Usage: ql3-cluster-admin <command> [arguments]',
|
||||||
'',
|
'',
|
||||||
'Remote client commands:',
|
'Cluster product commands:',
|
||||||
commands,
|
commands,
|
||||||
'',
|
'',
|
||||||
'Local operator commands:',
|
'Local operator commands:',
|
||||||
@@ -185,7 +191,8 @@ export function qingLong3ClusterProductHelp(): string {
|
|||||||
' context probe --context=/absolute/operator-context.json',
|
' context probe --context=/absolute/operator-context.json',
|
||||||
'',
|
'',
|
||||||
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
|
'Use `ql3-cluster-admin <command> --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.',
|
'Command and short-lived assertion files always remain explicit per invocation.',
|
||||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function jsonResponse(response, statusCode, requestId, body) {
|
|||||||
response.writeHead(statusCode, {
|
response.writeHead(statusCode, {
|
||||||
'content-type': 'application/json; charset=utf-8',
|
'content-type': 'application/json; charset=utf-8',
|
||||||
'content-length': String(bytes.length),
|
'content-length': String(bytes.length),
|
||||||
'x-request-id': requestId,
|
...(requestId === undefined ? {} : { 'x-request-id': requestId }),
|
||||||
});
|
});
|
||||||
response.end(bytes);
|
response.end(bytes);
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,7 @@ async function fixture(t) {
|
|||||||
);
|
);
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
const requests = [];
|
const requests = [];
|
||||||
|
const readiness = { value: 'ready' };
|
||||||
const server = createServer(
|
const server = createServer(
|
||||||
{
|
{
|
||||||
key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')),
|
key: fs.readFileSync(path.join(tlsFixture, 'server-key.pem')),
|
||||||
@@ -144,6 +145,15 @@ async function fixture(t) {
|
|||||||
peerCertificate: request.socket.getPeerCertificate(),
|
peerCertificate: request.socket.getPeerCertificate(),
|
||||||
body: chunks.length === 0 ? null : JSON.parse(Buffer.concat(chunks)),
|
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(
|
jsonResponse(
|
||||||
response,
|
response,
|
||||||
request.method === 'POST' && !request.url.endsWith('/cancellation') ? 201 : 200,
|
request.method === 'POST' && !request.url.endsWith('/cancellation') ? 201 : 200,
|
||||||
@@ -157,12 +167,17 @@ async function fixture(t) {
|
|||||||
server.once('error', reject);
|
server.once('error', reject);
|
||||||
server.listen(0, '127.0.0.1', resolve);
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
});
|
});
|
||||||
t.after(
|
let closed = false;
|
||||||
() =>
|
const close = () =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
server.close((error) => (error ? reject(error) : resolve()));
|
if (closed) {
|
||||||
}),
|
resolve();
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
t.after(close);
|
||||||
const caFile = privateFile(
|
const caFile = privateFile(
|
||||||
directory,
|
directory,
|
||||||
'ca.pem',
|
'ca.pem',
|
||||||
@@ -190,7 +205,7 @@ async function fixture(t) {
|
|||||||
maxConcurrentRequests: 2,
|
maxConcurrentRequests: 2,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return { requests, credentialFile, serverConfigFile };
|
return { requests, readiness, close, credentialFile, serverConfigFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function startClient(t, configFile) {
|
function startClient(t, configFile) {
|
||||||
@@ -271,7 +286,10 @@ function runCli(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('stdio CLI exposes deterministic help and low-sensitive startup failures', async () => {
|
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']), {
|
assert.deepEqual(await runCli(['--help']), {
|
||||||
status: 0,
|
status: 0,
|
||||||
signal: null,
|
signal: null,
|
||||||
@@ -298,6 +316,87 @@ test('stdio CLI exposes deterministic help and low-sensitive startup failures',
|
|||||||
assert.doesNotMatch(failed.stderr, /secret-config-name/);
|
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) => {
|
test('stdio MCP uses direct TLS client, rotates credentials and labels untrusted output', async (t) => {
|
||||||
const value = await fixture(t);
|
const value = await fixture(t);
|
||||||
const connected = startClient(t, value.serverConfigFile);
|
const connected = startClient(t, value.serverConfigFile);
|
||||||
|
|||||||
@@ -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(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(
|
assert.equal(
|
||||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
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(),
|
fs.lstatSync(path.join(packageRoot, 'dist', command.target)).isFile(),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(command.binary.includes('-client'), true);
|
assert.equal(
|
||||||
|
command.binary.includes('-client') ||
|
||||||
|
command.binary === 'ql3-copilot-mcp',
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const forbidden of [
|
for (const forbidden of [
|
||||||
'ql3-cluster-migrate',
|
'ql3-cluster-migrate',
|
||||||
@@ -375,6 +379,7 @@ test('help and version are bounded installation-derived product facts', () => {
|
|||||||
assert.match(help, /^Usage: ql3-cluster-admin <command> \[arguments\]/);
|
assert.match(help, /^Usage: ql3-cluster-admin <command> \[arguments\]/);
|
||||||
assert.match(help, /\n run\s+retry or stop Runs/);
|
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\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.match(help, /Server, migration, recovery, executor and key-custody/);
|
||||||
assert.equal(help.includes('plugin-package-manage'), false);
|
assert.equal(help.includes('plugin-package-manage'), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ const ENTRYPOINT = [
|
|||||||
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
|
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js',
|
||||||
];
|
];
|
||||||
const COMMANDS = Object.freeze([
|
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({
|
Object.freeze({
|
||||||
name: 'package',
|
name: 'package',
|
||||||
usage: 'Usage: ql3-plugin-package-client ',
|
usage: 'Usage: ql3-plugin-package-client ',
|
||||||
@@ -125,7 +133,9 @@ const validated = spawnSync(process.execPath, [facade, 'context', 'validate', '-
|
|||||||
let validationFact;
|
let validationFact;
|
||||||
try { validationFact = JSON.parse(validated.stdout); } catch { process.exit(25); }
|
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);
|
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));
|
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);
|
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);
|
if (!existsSync('/tmp/readiness-port')) process.exit(27);
|
||||||
|
|||||||
@@ -131,6 +131,105 @@ function assertClusterAdminImageCommands(readFile, root, findings) {
|
|||||||
return references;
|
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) {
|
function namedResource(resources, kind, name) {
|
||||||
return resources.find(
|
return resources.find(
|
||||||
(resource) => resource?.kind === kind && resource?.metadata?.name === name,
|
(resource) => resource?.kind === kind && resource?.metadata?.name === name,
|
||||||
@@ -5465,6 +5564,7 @@ function auditClusterDeployment(options = {}) {
|
|||||||
const readFile = options.readFile ?? fs.readFileSync;
|
const readFile = options.readFile ?? fs.readFileSync;
|
||||||
const findings = [];
|
const findings = [];
|
||||||
let clusterAdminImageReferences = 0;
|
let clusterAdminImageReferences = 0;
|
||||||
|
let clusterCopilotMcpHost = 'unavailable';
|
||||||
try {
|
try {
|
||||||
assertExactExternalClosure(readFile, root, findings);
|
assertExactExternalClosure(readFile, root, findings);
|
||||||
assertDockerfile(readFile, root, findings);
|
assertDockerfile(readFile, root, findings);
|
||||||
@@ -5474,6 +5574,11 @@ function auditClusterDeployment(options = {}) {
|
|||||||
root,
|
root,
|
||||||
findings,
|
findings,
|
||||||
);
|
);
|
||||||
|
clusterCopilotMcpHost = assertClusterCopilotMcpHostDeployment(
|
||||||
|
readFile,
|
||||||
|
root,
|
||||||
|
findings,
|
||||||
|
);
|
||||||
assertClusterAiComponent(readFile, root, findings);
|
assertClusterAiComponent(readFile, root, findings);
|
||||||
assertPluginPackageManagementDeployment(readFile, root, findings);
|
assertPluginPackageManagementDeployment(readFile, root, findings);
|
||||||
assertWorkerCredentialManagementDeployment(readFile, root, findings);
|
assertWorkerCredentialManagementDeployment(readFile, root, findings);
|
||||||
@@ -5526,6 +5631,7 @@ function auditClusterDeployment(options = {}) {
|
|||||||
promptOutputKeyRotation: 'caller-driven-staged-material',
|
promptOutputKeyRotation: 'caller-driven-staged-material',
|
||||||
clusterAi: 'optional-projected-authority',
|
clusterAi: 'optional-projected-authority',
|
||||||
clusterAiPromptOutput: 'optional-read-only-projected-keyring',
|
clusterAiPromptOutput: 'optional-read-only-projected-keyring',
|
||||||
|
clusterCopilotMcpHost,
|
||||||
imageReleasePins: 'independent-fail-closed-digests',
|
imageReleasePins: 'independent-fail-closed-digests',
|
||||||
clusterAdminImageReferences,
|
clusterAdminImageReferences,
|
||||||
findings: Object.freeze(findings),
|
findings: Object.freeze(findings),
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ function expectedImageConfig(architecture, revision, image) {
|
|||||||
? isControlAi
|
? isControlAi
|
||||||
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
|
? '/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-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',
|
WorkingDir: '/opt/qinglong',
|
||||||
Labels: {
|
Labels: {
|
||||||
@@ -240,7 +240,7 @@ function expectedImageConfig(architecture, revision, image) {
|
|||||||
? isControlAi
|
? isControlAi
|
||||||
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
|
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
|
||||||
: 'QingLong 3.0 PostgreSQL-backed 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.licenses': 'Apache-2.0',
|
||||||
'org.opencontainers.image.revision': revision,
|
'org.opencontainers.image.revision': revision,
|
||||||
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
|
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ test('accepts the exact locked non-root multi-replica cluster deployment', () =>
|
|||||||
report.clusterAiPromptOutput,
|
report.clusterAiPromptOutput,
|
||||||
'optional-read-only-projected-keyring',
|
'optional-read-only-projected-keyring',
|
||||||
);
|
);
|
||||||
|
assert.equal(report.clusterCopilotMcpHost, 'external-host-stdio');
|
||||||
assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material');
|
assert.equal(report.promptOutputKeyRotation, 'caller-driven-staged-material');
|
||||||
assert.equal(report.clusterAdminImageReferences, 24);
|
assert.equal(report.clusterAdminImageReferences, 24);
|
||||||
assert.deepEqual(report.workspacePackages, [
|
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', () => {
|
test('requires every Cluster Admin Kubernetes workload to override the image command', () => {
|
||||||
const report = auditClusterDeployment({
|
const report = auditClusterDeployment({
|
||||||
root: ROOT,
|
root: ROOT,
|
||||||
@@ -69,7 +102,8 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () =>
|
|||||||
root: ROOT,
|
root: ROOT,
|
||||||
readFile: intercept(
|
readFile: intercept(
|
||||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/role.yaml',
|
'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);
|
assert.equal(widenedRole.compatible, false);
|
||||||
@@ -84,7 +118,8 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () =>
|
|||||||
root: ROOT,
|
root: ROOT,
|
||||||
readFile: intercept(
|
readFile: intercept(
|
||||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/validating-admission-policy.yaml',
|
'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);
|
assert.equal(ignoredAdmissionFailure.compatible, false);
|
||||||
@@ -99,10 +134,11 @@ test('rejects widened Plugin Package Secret action Kubernetes authority', () =>
|
|||||||
root: ROOT,
|
root: ROOT,
|
||||||
readFile: intercept(
|
readFile: intercept(
|
||||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/secret-action-service-account.yaml',
|
'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/secret-action-service-account.yaml',
|
||||||
(source) => source.replace(
|
(source) =>
|
||||||
'automountServiceAccountToken: false',
|
source.replace(
|
||||||
'automountServiceAccountToken: true',
|
'automountServiceAccountToken: false',
|
||||||
),
|
'automountServiceAccountToken: true',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
assert.equal(actionToken.compatible, false);
|
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(), [
|
assert.deepEqual(Object.keys(example.commands).sort(), [
|
||||||
'approval',
|
'approval',
|
||||||
'automation',
|
'automation',
|
||||||
|
'copilot',
|
||||||
'model-credential',
|
'model-credential',
|
||||||
'package',
|
'package',
|
||||||
'package-kubernetes',
|
'package-kubernetes',
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function createFixture(t, options = {}) {
|
|||||||
? isControlAi
|
? isControlAi
|
||||||
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
|
? '/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-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',
|
WorkingDir: '/opt/qinglong',
|
||||||
Labels: {
|
Labels: {
|
||||||
@@ -135,7 +135,7 @@ function createFixture(t, options = {}) {
|
|||||||
? isControlAi
|
? isControlAi
|
||||||
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
|
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
|
||||||
: 'QingLong 3.0 PostgreSQL-backed 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.licenses': 'Apache-2.0',
|
||||||
'org.opencontainers.image.revision': revision,
|
'org.opencontainers.image.revision': revision,
|
||||||
'org.opencontainers.image.source':
|
'org.opencontainers.image.source':
|
||||||
|
|||||||
Reference in New Issue
Block a user