From c0ef9a969e2aa4f289f7acffc968a31ccd629be9 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 20 Aug 2026 15:15:42 +0800 Subject: [PATCH] feat(ql3): fence console capabilities by session --- deploy/console/ql3-cluster-copilot/README.md | 10 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 16 +++ ...ion-scoped-console-capability-discovery.md | 71 ++++++++++ docs/adr/README.md | 1 + .../assets/copilot-console/app.js | 113 +++++++++++++-- .../assets/copilot-console/index.html | 4 +- .../src/copilot-console/assets.ts | 4 +- .../src/copilot-console/cli.ts | 1 + .../src/copilot-console/server.ts | 132 +++++++++++++++++- .../test/copilotConsole.test.cjs | 104 +++++++++++++- scripts/ql3-cluster-copilot-console-audit.cjs | 13 ++ test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 12 files changed, 454 insertions(+), 19 deletions(-) create mode 100644 docs/adr/ADR-0470-session-scoped-console-capability-discovery.md diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index a792d2e1..edcdb329 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -214,6 +214,16 @@ then enter the session key from the private file. The browser keeps it only in page memory; reloading locks the page. Stop the process with `SIGINT` or `SIGTERM`, then remove or rotate the session file. +Unlock performs one authenticated same-origin +`POST /api/v1/session/capabilities` with one fixed schema-only body. This is a local configuration read with +`upstreamReads: 0`: it neither contacts the Cluster nor creates evidence. The +page hides optional operation groups that the process did not enable, while the +BFF independently checks the same immutable operation set before invoking any +executor. A disabled operation is masked as `404` even if a caller constructs +the fixed route manually. The thirteen base reads are mandatory; each optional +Run, Worker or Package authority group must be enabled completely or remains +disabled. + The BFF accepts at most two concurrent reads and sixteen connections, rejects a third request without queueing, caps request bodies at 4 KiB and responses at approximately 2 MiB, disables cache/cookies/frames/workers, and never polls. diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0240ffd8..f54ef8c2 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,22 @@ 最新增量证据(2026-08-20): +- D-377/ADR-0470(已接受):修复可选 Console authority 已默认关闭、浏览器却仍展示全部二十个操作的产品与授权边界漂移。Console CLI 现在把 + 完成私有文件校验后的唯一 `allowedOperations` 集合注入 server;十三个 Project/Copilot 基础只读操作必须完整存在,Run、Worker 与 Package + 三组可选操作只能整组启用或整组关闭,未知、重复、缺失基础操作或半组配置均在监听前失败。认证后的同源 + `POST /api/v1/session/capabilities` 只接受固定 schema-only body、读取进程内存配置,返回低敏 authority 状态、`mutation=false` 与 + `upstreamReads=0`,不调用 Cluster、 + manager、executor、repository 或数据库,也不进入证据账本。浏览器只有能力响应通过 fixed-route allowlist 校验后才解锁,隐藏未启用 tab, + session/allowed set 只保留在页内存;服务端同时在 body 解析和 executor 前对禁用 route 掩码 `404`,因此 UI 隐藏不是唯一安全边界。本切片不新增 + package、dependency、binary、端口、服务、数据库对象、Kubernetes workload、timer、poller、retry、queue 或 cache。聚焦 Console 门 + `38/38`;Cluster Admin 全量 `440 total / 437 pass / 3 conditional skip / 0 fail`;完整 backend + `1,505 total / 1,503 pass / 2 conditional skip / 0 fail`,并修正 D-376 后陈旧的包边界测试期望为 RFC 已声明的 Cluster Admin + `129 source / 128 nested`。18-package clean build/逐包测试单次退出 0;package boundary、Cluster dependency、Edge import、Cluster/Worker + deployment、Console 与 distribution 七项审计全部 compatible/passed,仍为 18 packages、`singleSourcePackages=[]`、 + `shallowSourcePackages=[]`。14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 保持 + `2,598,669 / 2,598,747` bytes、57 loaded modules,Application+AI 为 `4,501,822 / 4,501,954`,MCP 为 + `7,324,601 / 7,324,709`,证明能力发现未进入低配路由设备闭包。本切片不改变 PostgreSQL schema、ACL、repository、role、Pool、连接或 + failover 语义,因此不重跑且不重新占有 HA 证明;D-373/D-374 PostgreSQL 18.6 arm64 HA `146/146`、timeline `1→2` 仅作为相邻既有基线。 - D-376/ADR-0469(已接受):在既有 operator-workstation、loopback-only Copilot Console 内增加可选 Plugin Package installation 观察,而不新增 workspace package、服务、端口或集群工作负载。Browser/BFF 只增加固定 `package_list|package_inspect` 与 `/api/v1/package-management/installations|installation`;上游复用 canonical `/api/v3/plugin-packages/management` client,独立 diff --git a/docs/adr/ADR-0470-session-scoped-console-capability-discovery.md b/docs/adr/ADR-0470-session-scoped-console-capability-discovery.md new file mode 100644 index 00000000..c60b05de --- /dev/null +++ b/docs/adr/ADR-0470-session-scoped-console-capability-discovery.md @@ -0,0 +1,71 @@ +# ADR-0470:Console 会话级能力发现与服务端操作围栏 + +- 状态:Accepted +- 日期:2026-08-20 +- 关联 RFC:QL-RFC-0001 D-377、D-376、D-375、D-107 +- 关联 ADR:ADR-0419、ADR-0468、ADR-0469 + +## 上下文 + +Copilot Console 的 Run、Worker 与 Package management 观察权限默认关闭并分别由启动进程显式提供,但 D-376 页面在会话解锁后仍展示全部 +二十个操作。用户只能在点击后从失败响应推断 authority 未启用;仅隐藏前端按钮也无法证明手工构造固定 route 不会到达 executor。 + +能力发现不能变成新的 Cluster inventory、轮询器或状态服务。它必须只反映当前 Console 进程已经验证的静态启动边界,不访问上游、不生成证据、 +不增加常驻任务,并继续适用于资源有限的工作站与管理节点。Edge/Standalone 路由设备不得因此装入 Cluster Admin。 + +## 决策 + +1. `startClusterCopilotConsoleServer` 必须显式接收 `allowedOperations`。十三个 Project/Copilot 基础只读操作必须完整存在;Run、Worker、Package + 三组可选操作分别只能整组启用或整组关闭。未知、重复、缺失基础操作或部分可选组均在监听前 fail closed。 +2. CLI 只把已经完成私有文件和 authority 校验的 `availableOperations` 注入服务端。preflight、started event、能力响应和执行围栏共享同一有序操作集, + 不再存在 UI 声明与实际启动权限的第二份配置。 +3. 增加认证后的同源 `POST /api/v1/session/capabilities`,请求只接受固定 schema-only body 并复用 Origin、media type、长度和并发上限。响应固定为 + `qinglong/cluster-copilot-console-capabilities@v1`,只包含有序操作、四个低敏 authority 状态、`mutation: false` 与 `upstreamReads: 0`。 + 未认证、Host/Origin/session 不匹配仍统一返回掩码 `404`。 +4. 能力读取是本进程内存配置读取,不调用 Cluster client、management client、executor、repository 或数据库,不写浏览器证据账本,也不增加 + poller、retry、queue、cache、watcher、WebSocket/SSE 或 timer。 +5. 服务端在解析 read body 和调用 executor 之前检查固定 route 对应操作是否属于 immutable allowed set。禁用操作返回掩码 `404` 并丢弃请求, + 即使调用者绕过页面手工构造 route 也不能获得上游 authority。 +6. 浏览器只有在 session 格式校验和 capability 请求成功后才解锁。它拒绝未知、重复或非固定 route 的能力响应,隐藏无可用操作的可选 tab, + 禁用未授权按钮,并在每次执行前再次检查本页 allowed set。session 与 allowed set 只存在当前页面内存,pagehide 时清空。 +7. 本切片不新增 workspace package、dependency、binary、端口、服务、数据库对象、Kubernetes workload 或后台资源。能力发现只增加一次解锁请求, + 不进入 Edge/Standalone artifact closure。 + +## 被拒绝的替代方案 + +### 继续展示全部操作并在点击后报错 + +拒绝。它把部署配置问题推迟成运行时失败,也误导用户认为可选 authority 已存在;对低性能管理设备还会产生无意义请求。 + +### 只在 HTML/JavaScript 中按部署模板隐藏 tab + +拒绝。静态资产无法知道当前进程实际验证了哪些私有文件,而且浏览器隐藏不构成授权边界,手工请求仍可能到达 executor。 + +### 从每个上游 manager 动态探测能力 + +拒绝。它会把一次页面解锁扩展成多次 Cluster 网络请求,制造额外延迟、故障耦合和隐式负载。启动进程已经拥有经过校验的唯一事实源。 + +### 允许任意操作子集 + +拒绝。当前产品权限是十三个基础读与三个完整可选 authority 组。任意子集会制造未定义的半启用部署和更多测试组合;更细粒度授权需要独立 RFC。 + +## 升级与回滚 + +- 现有 CLI 自动注入与 preflight 相同的操作集;默认部署解锁后只显示十三个基础操作。显式启用某个 management authority 时相应 tab 整组出现。 +- 直接调用导出的 server factory 必须提供合法 `allowedOperations`,这是有意的 fail-closed API 收紧;无隐式“全部允许”兼容默认值。 +- 回滚到 ADR-0469 可移除 capability endpoint、浏览器发现流程和 server option,不涉及数据迁移或 Cluster 配置变更。 + +## 验证与证据 + +- Console server、CLI、证据生成与离线校验聚焦门 `38/38`,覆盖认证能力读取、零上游读取声明、基础操作强制存在、可选组完整性、重复操作拒绝、 + 禁用 route 在 executor 前掩码拒绝,以及原有二十个 read contract。 +- `@qinglong/cluster-admin` 全量 `440 total / 437 pass / 3 conditional skip / 0 fail`;完整 backend + `1,505 total / 1,503 pass / 2 conditional skip / 0 fail`。D-376 新增内聚源文件后遗留的 package boundary 期望已从 `128/127` 修正为 + 实际且 RFC 已声明的 `129 source / 128 nested`。 +- 18-package clean build 与逐包测试单次退出 0。package boundary、Cluster dependency、Edge import、Cluster/Worker deployment、Console 与 + Console distribution 七项审计全部 compatible/passed;workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`, + 未新增依赖。 +- 14 档 Local artifact audit 全部 compatible。基础 Edge/Standalone 仍为 `2,598,669 / 2,598,747` bytes、57 loaded modules; + Application+AI 为 `4,501,822 / 4,501,954` bytes;MCP 为 `7,324,601 / 7,324,709` bytes。Console capability discovery 未进入低配闭包。 +- 本切片不改变 PostgreSQL schema、ACL、repository、role、Pool、连接或 failover 语义,因此不重跑且不重新占有 HA 证明;D-373/D-374 的 + PostgreSQL 18.6 arm64 HA `146/146`、timeline `1→2` 只作为相邻既有基线,数据库语义变化时必须重跑。 diff --git a/docs/adr/README.md b/docs/adr/README.md index f539aa9e..fb922bb8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -473,6 +473,7 @@ | [ADR-0467](./ADR-0467-generic-worker-management-product-entry.md) | 通用 Worker Management 产品入口与兼容路径 | Accepted | | [ADR-0468](./ADR-0468-optional-console-worker-observation.md) | 可选 Console Worker 只读观察 | Accepted | | [ADR-0469](./ADR-0469-optional-console-package-installation-observation.md) | 可选 Console Package Installation 只读观察 | Accepted | +| [ADR-0470](./ADR-0470-session-scoped-console-capability-discovery.md) | Console 会话级能力发现与服务端操作围栏 | Accepted | ## 规则 diff --git a/packages/ql3-cluster-admin/assets/copilot-console/app.js b/packages/ql3-cluster-admin/assets/copilot-console/app.js index 65dfd520..eb590299 100644 --- a/packages/ql3-cluster-admin/assets/copilot-console/app.js +++ b/packages/ql3-cluster-admin/assets/copilot-console/app.js @@ -2,6 +2,9 @@ (function () { const schema = 'qinglong/cluster-copilot-console-read-request@v1'; + const capabilityRequestSchema = + 'qinglong/cluster-copilot-console-capabilities-request@v1'; + const capabilitySchema = 'qinglong/cluster-copilot-console-capabilities@v1'; const routes = Object.freeze({ inspect: '/api/v1/copilot/inspect', output: '/api/v1/copilot/output', @@ -55,11 +58,13 @@ const emptyState = document.getElementById('empty-state'); const message = document.getElementById('message'); const statusChip = document.getElementById('status-chip'); + const authoritySummary = document.getElementById('authority-summary'); const ledgerMeta = document.getElementById('ledger-meta'); const exportButton = document.getElementById('export-evidence'); const clearButton = document.getElementById('clear-evidence'); const evidenceRecords = []; let sessionToken = ''; + let allowedOperations = new Set(); let busy = false; let exporting = false; let evidenceBytes = 0; @@ -95,7 +100,7 @@ const setBusy = function (next) { busy = next; document.querySelectorAll('[data-read]').forEach(function (button) { - button.disabled = next; + button.disabled = next || !allowedOperations.has(button.dataset.read); }); if (next) { statusChip.textContent = '读取中'; @@ -107,6 +112,72 @@ updateLedgerState(); }; + const applyCapabilities = function (capabilities) { + allowedOperations = new Set(capabilities.operations); + document.querySelectorAll('[data-read]').forEach(function (button) { + const available = allowedOperations.has(button.dataset.read); + button.hidden = !available; + button.disabled = !available; + }); + document.querySelectorAll('.mode-tab').forEach(function (tab) { + const panel = document.getElementById(tab.dataset.panel); + const available = Array.from(panel.querySelectorAll('[data-read]')).some( + function (button) { + return allowedOperations.has(button.dataset.read); + }, + ); + tab.hidden = !available; + tab.classList.toggle('active', tab.dataset.panel === 'runtime-panel'); + tab.setAttribute( + 'aria-pressed', + String(tab.dataset.panel === 'runtime-panel'), + ); + panel.hidden = tab.dataset.panel !== 'runtime-panel'; + }); + const enabled = ['Run', 'Task', 'Workflow', 'Copilot']; + if (allowedOperations.has('run_cancellation_status')) { + enabled.push('取消诊断'); + } + if (allowedOperations.has('worker_list')) enabled.push('Worker'); + if (allowedOperations.has('package_list')) enabled.push('Package'); + authoritySummary.textContent = enabled.join(' · '); + }; + + const discoverCapabilities = async function () { + const response = await fetch('/api/v1/session/capabilities', { + method: 'POST', + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + Authorization: 'QL3-Console ' + sessionToken, + }, + body: JSON.stringify({ schema: capabilityRequestSchema }), + }); + const body = await response.json(); + if (!response.ok) { + throw new Error( + typeof body.code === 'string' ? body.code : 'capability_read_failed', + ); + } + if ( + body.schema !== capabilitySchema || + !Array.isArray(body.operations) || + body.operations.length !== new Set(body.operations).size || + body.operations.some(function (operation) { + return ( + typeof operation !== 'string' || !Object.hasOwn(routes, operation) + ); + }) + ) { + throw new Error('capability_response_invalid'); + } + return body; + }; + const base = function (operation) { const projectId = value('project-id'); if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) { @@ -363,7 +434,7 @@ }; const execute = async function (operation, prepared) { - if (busy) return; + if (busy || !allowedOperations.has(operation)) return; setBusy(true); setMessage('正在读取 ' + labels[operation] + '…'); try { @@ -477,21 +548,42 @@ } }; - sessionForm.addEventListener('submit', function (event) { + sessionForm.addEventListener('submit', async function (event) { event.preventDefault(); const candidate = sessionInput.value.trim(); if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) { setMessage('浏览器访问密钥格式无效。', 'error'); return; } + const submitButton = sessionForm.querySelector('button[type="submit"]'); + submitButton.disabled = true; sessionToken = candidate; - sessionInput.value = ''; - sessionForm.hidden = true; - controls.hidden = false; - statusChip.textContent = '只读就绪'; - statusChip.dataset.tone = 'success'; - setMessage('本页已解锁;Cluster credential 仍只存在于服务端。', 'success'); - document.getElementById('project-id').focus(); + setMessage('正在核验会话并读取本机能力边界…'); + try { + const capabilities = await discoverCapabilities(); + applyCapabilities(capabilities); + sessionInput.value = ''; + sessionForm.hidden = true; + controls.hidden = false; + statusChip.textContent = '只读就绪'; + statusChip.dataset.tone = 'success'; + setMessage( + '本页已解锁并仅显示服务端启用的 ' + + String(capabilities.operations.length) + + ' 个只读操作;Cluster credential 仍只存在于服务端。', + 'success', + ); + document.getElementById('project-id').focus(); + } catch (error) { + sessionToken = ''; + setMessage( + '无法解锁:' + + (error instanceof Error ? error.message : 'capability_read_failed'), + 'error', + ); + } finally { + submitButton.disabled = false; + } }); document.querySelectorAll('.mode-tab').forEach(function (tab) { @@ -521,6 +613,7 @@ window.addEventListener('pagehide', function () { sessionToken = ''; + allowedOperations.clear(); evidenceRecords.length = 0; evidenceBytes = 0; ledger.textContent = ''; diff --git a/packages/ql3-cluster-admin/assets/copilot-console/index.html b/packages/ql3-cluster-admin/assets/copilot-console/index.html index 97a27415..577da6de 100644 --- a/packages/ql3-cluster-admin/assets/copilot-console/index.html +++ b/packages/ql3-cluster-admin/assets/copilot-console/index.html @@ -24,7 +24,7 @@
本机只读 BFF - Run · Task · Workflow · Worker · Package · Copilot + 等待会话能力
@@ -191,7 +191,7 @@ diff --git a/packages/ql3-cluster-admin/src/copilot-console/assets.ts b/packages/ql3-cluster-admin/src/copilot-console/assets.ts index 26280133..bced1818 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/assets.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/assets.ts @@ -24,7 +24,7 @@ const ASSETS = Object.freeze([ name: 'index.html', field: 'html', maximumBytes: 32 * 1024, - digest: '429d7b3dd2da4989865be6ac07180cc9c3ebdcbbac5028054cddaac870ad520c', + digest: '7e024103aaf03553995b09bfb9d6da5ba058ce77f7fff65c56b564017c79f28a', }), Object.freeze({ name: 'app.css', @@ -42,7 +42,7 @@ const ASSETS = Object.freeze([ name: 'app.js', field: 'javascript', maximumBytes: 32 * 1024, - digest: '365ccd43ae2aa4b11a0ab3d142cd04e89ec0b96711bef253f63584e8182589ee', + digest: '224895776a1df1b4571a8cdb9ce8126861d0284e1fef801e6de3ef31ca96c931', }), ] as const); diff --git a/packages/ql3-cluster-admin/src/copilot-console/cli.ts b/packages/ql3-cluster-admin/src/copilot-console/cli.ts index d585bdb9..5da52a0b 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/cli.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/cli.ts @@ -577,6 +577,7 @@ async function main(): Promise { } const server = await startClusterCopilotConsoleServer({ + allowedOperations: operations, assets, executor: Object.freeze({ execute(request: Readonly) { diff --git a/packages/ql3-cluster-admin/src/copilot-console/server.ts b/packages/ql3-cluster-admin/src/copilot-console/server.ts index 4c42758b..12f5340e 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/server.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/server.ts @@ -17,6 +17,7 @@ import { } from '../management-support/pluginPackageManagementClient'; import { type ClusterCopilotConsoleAssets } from './assets'; import { + CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA, InvalidClusterCopilotConsoleReadRequestError, normalizeClusterCopilotConsoleReadRequest, @@ -43,6 +44,7 @@ export interface ClusterCopilotConsoleExecutor { } export interface ClusterCopilotConsoleServerOptions { + readonly allowedOperations: readonly ClusterCopilotConsoleReadOperation[]; readonly assets: Readonly; readonly executor: ClusterCopilotConsoleExecutor; readonly networkBoundary?: ClusterCopilotConsoleNetworkBoundary; @@ -69,6 +71,31 @@ export class ClusterCopilotConsoleConfigurationError extends TypeError { } const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/; +const CAPABILITIES_PATH = '/api/v1/session/capabilities'; +const CAPABILITIES_REQUEST_SCHEMA = + 'qinglong/cluster-copilot-console-capabilities-request@v1'; +const CAPABILITIES_SCHEMA = 'qinglong/cluster-copilot-console-capabilities@v1'; +const RUN_MANAGEMENT_OPERATIONS = Object.freeze([ + 'run_cancellation_status', + 'run_cancellation_blocked_list', + 'run_cancellation_inspect', +] as const satisfies readonly ClusterCopilotConsoleReadOperation[]); +const WORKER_MANAGEMENT_OPERATIONS = Object.freeze([ + 'worker_list', + 'worker_inspect', +] as const satisfies readonly ClusterCopilotConsoleReadOperation[]); +const PACKAGE_MANAGEMENT_OPERATIONS = Object.freeze([ + 'package_list', + 'package_inspect', +] as const satisfies readonly ClusterCopilotConsoleReadOperation[]); +const OPTIONAL_OPERATION_GROUPS = Object.freeze([ + RUN_MANAGEMENT_OPERATIONS, + WORKER_MANAGEMENT_OPERATIONS, + PACKAGE_MANAGEMENT_OPERATIONS, +]); +const OPTIONAL_OPERATIONS = new Set( + OPTIONAL_OPERATION_GROUPS.flat(), +); const SESSION_DIGEST_DOMAIN = Buffer.from( 'qinglong-cluster-copilot-console-session-v1\0', 'utf8', @@ -219,6 +246,57 @@ function targetPath( return request.url === undefined ? null : READ_ROUTES[request.url] ?? null; } +function validatedAllowedOperations( + value: unknown, +): readonly ClusterCopilotConsoleReadOperation[] { + if (!Array.isArray(value)) return invalid(); + const canonical = new Set( + CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, + ); + const operations = value as ClusterCopilotConsoleReadOperation[]; + const selected = new Set(operations); + if ( + selected.size !== operations.length || + operations.some((operation) => !canonical.has(operation)) || + CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.some( + (operation) => + !OPTIONAL_OPERATIONS.has(operation) && !selected.has(operation), + ) || + OPTIONAL_OPERATION_GROUPS.some((group) => { + const selectedCount = group.filter((operation) => + selected.has(operation), + ).length; + return selectedCount !== 0 && selectedCount !== group.length; + }) + ) { + return invalid(); + } + return Object.freeze( + CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.filter((operation) => + selected.has(operation), + ), + ); +} + +function operationGroupEnabled( + allowedOperations: ReadonlySet, + operations: readonly ClusterCopilotConsoleReadOperation[], +): boolean { + return operations.every((operation) => allowedOperations.has(operation)); +} + +function validateCapabilitiesRequest(value: unknown): void { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length !== 1 || + (value as Record).schema !== CAPABILITIES_REQUEST_SCHEMA + ) { + throw new InvalidClusterCopilotConsoleReadRequestError(); + } +} + function authorize( request: IncomingMessage, expectedOrigin: string, @@ -331,7 +409,13 @@ function remoteFailure( export async function startClusterCopilotConsoleServer( options: ClusterCopilotConsoleServerOptions, ): Promise> { - const optionKeys = ['assets', 'executor', 'port', 'sessionDigest']; + const optionKeys = [ + 'allowedOperations', + 'assets', + 'executor', + 'port', + 'sessionDigest', + ]; if (Object.hasOwn(options, 'networkBoundary')) { optionKeys.push('networkBoundary'); } @@ -342,6 +426,8 @@ export async function startClusterCopilotConsoleServer( 'html', 'javascript', ]); + const operations = validatedAllowedOperations(record.allowedOperations); + const allowedOperations = new Set(operations); const networkBoundary = record.networkBoundary === undefined ? 'host-loopback' @@ -412,16 +498,23 @@ export async function startClusterCopilotConsoleServer( } } + const capabilitiesRequest = + request.method === 'POST' && request.url === CAPABILITIES_PATH; const operation = targetPath(request); if ( !hostMatches || - operation === null || + (!capabilitiesRequest && operation === null) || !authorize(request, expectedOrigin, sessionDigest) ) { sendJson(response, 404, Object.freeze({ code: 'not_found' })); request.resume(); return; } + if (!capabilitiesRequest && !allowedOperations.has(operation!)) { + sendJson(response, 404, Object.freeze({ code: 'not_found' })); + request.resume(); + return; + } if (inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests) { sendJson( response, @@ -439,6 +532,41 @@ export async function startClusterCopilotConsoleServer( inFlight += 1; try { const body = await readJsonBody(request); + if (capabilitiesRequest) { + validateCapabilitiesRequest(body); + sendJson( + response, + 200, + Object.freeze({ + schema: CAPABILITIES_SCHEMA, + operations, + authorities: Object.freeze({ + projectObservation: 'server_only', + runManagement: operationGroupEnabled( + allowedOperations, + RUN_MANAGEMENT_OPERATIONS, + ) + ? 'server_only' + : 'disabled', + workerManagement: operationGroupEnabled( + allowedOperations, + WORKER_MANAGEMENT_OPERATIONS, + ) + ? 'server_only' + : 'disabled', + packageManagement: operationGroupEnabled( + allowedOperations, + PACKAGE_MANAGEMENT_OPERATIONS, + ) + ? 'server_only' + : 'disabled', + }), + mutation: false, + upstreamReads: 0, + }), + ); + return; + } const normalized = normalizeClusterCopilotConsoleReadRequest(body); if (normalized.operation !== operation) { throw new InvalidClusterCopilotConsoleReadRequestError(); diff --git a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs index e8e872f2..b314deff 100644 --- a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs +++ b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs @@ -14,6 +14,7 @@ const { loadClusterCopilotConsoleAssets, } = require('../dist/copilot-console/assets.js'); const { + CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA, clusterCopilotConsoleClientCommand, clusterCopilotConsoleProjectReadPath, @@ -26,6 +27,18 @@ const { } = require('../dist/copilot-console/server.js'); const moduleDirectory = resolve(__dirname, '../dist/copilot-console'); +const optionalOperations = new Set([ + 'run_cancellation_status', + 'run_cancellation_blocked_list', + 'run_cancellation_inspect', + 'worker_list', + 'worker_inspect', + 'package_list', + 'package_inspect', +]); +const baseOperations = CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.filter( + (operation) => !optionalOperations.has(operation), +); function target(operation = 'inspect') { return { @@ -151,9 +164,13 @@ function request(origin, options = {}) { }); } -async function fixture(execute = async () => inspection()) { +async function fixture( + execute = async () => inspection(), + allowedOperations = CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, +) { const token = randomBytes(32).toString('base64url'); const server = await startClusterCopilotConsoleServer({ + allowedOperations, assets: loadClusterCopilotConsoleAssets(moduleDirectory), executor: { execute }, port: 0, @@ -501,6 +518,7 @@ test('allows only an explicit fixed-port container listener behind host loopback const token = randomBytes(32).toString('base64url'); await assert.rejects( startClusterCopilotConsoleServer({ + allowedOperations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, assets: loadClusterCopilotConsoleAssets(moduleDirectory), executor: { execute: async () => inspection() }, networkBoundary: 'container-published-loopback', @@ -510,6 +528,7 @@ test('allows only an explicit fixed-port container listener behind host loopback ClusterCopilotConsoleConfigurationError, ); const server = await startClusterCopilotConsoleServer({ + allowedOperations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, assets: loadClusterCopilotConsoleAssets(moduleDirectory), executor: { execute: async () => inspection() }, networkBoundary: 'container-published-loopback', @@ -521,6 +540,89 @@ test('allows only an explicit fixed-port container listener behind host loopback assert.equal((await request(server.origin)).statusCode, 200); }); +test('discovers session capabilities locally and fences disabled operations before execution', async (t) => { + const reads = []; + const { server, headers } = await fixture(async (read) => { + reads.push(read); + return inspection(); + }, baseOperations); + t.after(() => server.close()); + + const unauthenticated = await request(server.origin, { + method: 'POST', + path: '/api/v1/session/capabilities', + }); + assert.equal(unauthenticated.statusCode, 404); + + const invalidCapabilities = await request(server.origin, { + method: 'POST', + path: '/api/v1/session/capabilities', + headers, + body: {}, + }); + assert.equal(invalidCapabilities.statusCode, 400); + + const capabilities = await request(server.origin, { + method: 'POST', + path: '/api/v1/session/capabilities', + headers, + body: { + schema: 'qinglong/cluster-copilot-console-capabilities-request@v1', + }, + }); + assert.equal(capabilities.statusCode, 200); + assert.deepEqual(capabilities.body, { + schema: 'qinglong/cluster-copilot-console-capabilities@v1', + operations: baseOperations, + authorities: { + projectObservation: 'server_only', + runManagement: 'disabled', + workerManagement: 'disabled', + packageManagement: 'disabled', + }, + mutation: false, + upstreamReads: 0, + }); + + const disabled = await request(server.origin, { + method: 'POST', + path: '/api/v1/package-management/installations', + headers, + body: { + schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA, + operation: 'package_list', + projectId: 'project-main', + requestId: 'console-package-list-1', + afterPackageName: null, + }, + }); + assert.equal(disabled.statusCode, 404); + assert.deepEqual(reads, []); +}); + +test('rejects duplicate, incomplete base and partial optional operation sets', async () => { + const token = randomBytes(32).toString('base64url'); + const common = { + assets: loadClusterCopilotConsoleAssets(moduleDirectory), + executor: { execute: async () => inspection() }, + port: 0, + sessionDigest: clusterCopilotConsoleSessionDigest(token), + }; + for (const allowedOperations of [ + [...CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS, 'inspect'], + baseOperations.filter((operation) => operation !== 'inspect'), + [...baseOperations, 'run_cancellation_status'], + ]) { + await assert.rejects( + startClusterCopilotConsoleServer({ + allowedOperations, + ...common, + }), + ClusterCopilotConsoleConfigurationError, + ); + } +}); + test('keeps the Cluster credential server-side and forwards one exact inspect', async (t) => { const commands = []; const { server, headers } = await fixture(async (command) => { diff --git a/scripts/ql3-cluster-copilot-console-audit.cjs b/scripts/ql3-cluster-copilot-console-audit.cjs index 3e53e177..0a4b7684 100644 --- a/scripts/ql3-cluster-copilot-console-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-audit.cjs @@ -145,6 +145,10 @@ function auditClusterCopilotConsole(options = {}) { "'/api/v1/run-management/cancellation-status': 'run_cancellation_status'", "'/api/v1/worker-management/workers': 'worker_list'", "'/api/v1/package-management/installations': 'package_list'", + "const CAPABILITIES_PATH = '/api/v1/session/capabilities'", + 'validatedAllowedOperations(record.allowedOperations)', + 'if (!capabilitiesRequest && !allowedOperations.has(operation!))', + 'upstreamReads: 0', "'/api/v1/observe/run-list': 'run_list'", "'/api/v1/observe/task-list': 'task_list'", "'/api/v1/observe/workflow-list': 'workflow_list'", @@ -181,6 +185,7 @@ function auditClusterCopilotConsole(options = {}) { 'runManagementAuthority: runManagementAuthority', 'workerManagementAuthority: workerManagementAuthority', 'packageManagementAuthority: packageManagementAuthority', + 'allowedOperations: operations', 'mutation: false', ]); rejectFragments(CONSOLE_ROOT + '/cli.ts', [ @@ -246,6 +251,8 @@ function auditClusterCopilotConsole(options = {}) { '该只读面没有 rearm', '模型文本是不可信内容', '导出脱敏包', + 'id="authority-summary"', + 'QingLong 3.0 incubation / D-377', '/evidence-bundle.js', ]); expectFragments(ASSET_ROOT + '/app.js', [ @@ -256,6 +263,9 @@ function auditClusterCopilotConsole(options = {}) { 'measureClusterConsoleEvidenceRecord(record)', 'createClusterConsoleEvidenceBundle', 'URL.revokeObjectURL(objectUrl)', + "fetch('/api/v1/session/capabilities'", + 'applyCapabilities(capabilities)', + 'allowedOperations.has(operation)', "sessionToken = ''", ]); rejectFragments(ASSET_ROOT + '/app.js', [ @@ -312,6 +322,9 @@ function auditClusterCopilotConsole(options = {}) { 'thirteen exact operations', 'available vocabulary to sixteen', 'maximum vocabulary of twenty operations', + 'POST /api/v1/session/capabilities', + '`upstreamReads: 0`', + 'BFF independently checks the same immutable operation set', 'QL3_COPILOT_CONSOLE_RUN_MANAGEMENT=enabled', 'QL3_COPILOT_CONSOLE_WORKER_MANAGEMENT=enabled', 'QL3_COPILOT_CONSOLE_PACKAGE_MANAGEMENT=enabled', diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 933da833..e6589cef 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 128, + sourceFiles: 129, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 127, + nestedSourceFiles: 128, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', },