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 @@