mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): fence console capabilities by session
This commit is contained in:
@@ -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
|
page memory; reloading locks the page. Stop the process with `SIGINT` or
|
||||||
`SIGTERM`, then remove or rotate the session file.
|
`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
|
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
|
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.
|
approximately 2 MiB, disables cache/cookies/frames/workers, and never polls.
|
||||||
|
|||||||
@@ -11,6 +11,22 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-20):
|
最新增量证据(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
|
- D-376/ADR-0469(已接受):在既有 operator-workstation、loopback-only Copilot Console 内增加可选 Plugin Package installation
|
||||||
观察,而不新增 workspace package、服务、端口或集群工作负载。Browser/BFF 只增加固定 `package_list|package_inspect` 与
|
观察,而不新增 workspace package、服务、端口或集群工作负载。Browser/BFF 只增加固定 `package_list|package_inspect` 与
|
||||||
`/api/v1/package-management/installations|installation`;上游复用 canonical `/api/v3/plugin-packages/management` client,独立
|
`/api/v1/package-management/installations|installation`;上游复用 canonical `/api/v3/plugin-packages/management` client,独立
|
||||||
|
|||||||
@@ -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` 只作为相邻既有基线,数据库语义变化时必须重跑。
|
||||||
@@ -473,6 +473,7 @@
|
|||||||
| [ADR-0467](./ADR-0467-generic-worker-management-product-entry.md) | 通用 Worker Management 产品入口与兼容路径 | Accepted |
|
| [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-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-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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const schema = 'qinglong/cluster-copilot-console-read-request@v1';
|
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({
|
const routes = Object.freeze({
|
||||||
inspect: '/api/v1/copilot/inspect',
|
inspect: '/api/v1/copilot/inspect',
|
||||||
output: '/api/v1/copilot/output',
|
output: '/api/v1/copilot/output',
|
||||||
@@ -55,11 +58,13 @@
|
|||||||
const emptyState = document.getElementById('empty-state');
|
const emptyState = document.getElementById('empty-state');
|
||||||
const message = document.getElementById('message');
|
const message = document.getElementById('message');
|
||||||
const statusChip = document.getElementById('status-chip');
|
const statusChip = document.getElementById('status-chip');
|
||||||
|
const authoritySummary = document.getElementById('authority-summary');
|
||||||
const ledgerMeta = document.getElementById('ledger-meta');
|
const ledgerMeta = document.getElementById('ledger-meta');
|
||||||
const exportButton = document.getElementById('export-evidence');
|
const exportButton = document.getElementById('export-evidence');
|
||||||
const clearButton = document.getElementById('clear-evidence');
|
const clearButton = document.getElementById('clear-evidence');
|
||||||
const evidenceRecords = [];
|
const evidenceRecords = [];
|
||||||
let sessionToken = '';
|
let sessionToken = '';
|
||||||
|
let allowedOperations = new Set();
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let exporting = false;
|
let exporting = false;
|
||||||
let evidenceBytes = 0;
|
let evidenceBytes = 0;
|
||||||
@@ -95,7 +100,7 @@
|
|||||||
const setBusy = function (next) {
|
const setBusy = function (next) {
|
||||||
busy = next;
|
busy = next;
|
||||||
document.querySelectorAll('[data-read]').forEach(function (button) {
|
document.querySelectorAll('[data-read]').forEach(function (button) {
|
||||||
button.disabled = next;
|
button.disabled = next || !allowedOperations.has(button.dataset.read);
|
||||||
});
|
});
|
||||||
if (next) {
|
if (next) {
|
||||||
statusChip.textContent = '读取中';
|
statusChip.textContent = '读取中';
|
||||||
@@ -107,6 +112,72 @@
|
|||||||
updateLedgerState();
|
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 base = function (operation) {
|
||||||
const projectId = value('project-id');
|
const projectId = value('project-id');
|
||||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) {
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) {
|
||||||
@@ -363,7 +434,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const execute = async function (operation, prepared) {
|
const execute = async function (operation, prepared) {
|
||||||
if (busy) return;
|
if (busy || !allowedOperations.has(operation)) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setMessage('正在读取 ' + labels[operation] + '…');
|
setMessage('正在读取 ' + labels[operation] + '…');
|
||||||
try {
|
try {
|
||||||
@@ -477,21 +548,42 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionForm.addEventListener('submit', function (event) {
|
sessionForm.addEventListener('submit', async function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const candidate = sessionInput.value.trim();
|
const candidate = sessionInput.value.trim();
|
||||||
if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) {
|
if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) {
|
||||||
setMessage('浏览器访问密钥格式无效。', 'error');
|
setMessage('浏览器访问密钥格式无效。', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const submitButton = sessionForm.querySelector('button[type="submit"]');
|
||||||
|
submitButton.disabled = true;
|
||||||
sessionToken = candidate;
|
sessionToken = candidate;
|
||||||
sessionInput.value = '';
|
setMessage('正在核验会话并读取本机能力边界…');
|
||||||
sessionForm.hidden = true;
|
try {
|
||||||
controls.hidden = false;
|
const capabilities = await discoverCapabilities();
|
||||||
statusChip.textContent = '只读就绪';
|
applyCapabilities(capabilities);
|
||||||
statusChip.dataset.tone = 'success';
|
sessionInput.value = '';
|
||||||
setMessage('本页已解锁;Cluster credential 仍只存在于服务端。', 'success');
|
sessionForm.hidden = true;
|
||||||
document.getElementById('project-id').focus();
|
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) {
|
document.querySelectorAll('.mode-tab').forEach(function (tab) {
|
||||||
@@ -521,6 +613,7 @@
|
|||||||
|
|
||||||
window.addEventListener('pagehide', function () {
|
window.addEventListener('pagehide', function () {
|
||||||
sessionToken = '';
|
sessionToken = '';
|
||||||
|
allowedOperations.clear();
|
||||||
evidenceRecords.length = 0;
|
evidenceRecords.length = 0;
|
||||||
evidenceBytes = 0;
|
evidenceBytes = 0;
|
||||||
ledger.textContent = '';
|
ledger.textContent = '';
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="boundary" aria-label="当前权限边界">
|
<div class="boundary" aria-label="当前权限边界">
|
||||||
<span class="boundary-dot" aria-hidden="true"></span>
|
<span class="boundary-dot" aria-hidden="true"></span>
|
||||||
<span>本机只读 BFF</span>
|
<span>本机只读 BFF</span>
|
||||||
<strong>Run · Task · Workflow · Worker · Package · Copilot</strong>
|
<strong id="authority-summary">等待会话能力</strong>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>Loopback only · explicit reads · zero polling</span>
|
<span>Loopback only · explicit reads · zero polling</span>
|
||||||
<span>QingLong 3.0 incubation / D-376</span>
|
<span>QingLong 3.0 incubation / D-377</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ASSETS = Object.freeze([
|
|||||||
name: 'index.html',
|
name: 'index.html',
|
||||||
field: 'html',
|
field: 'html',
|
||||||
maximumBytes: 32 * 1024,
|
maximumBytes: 32 * 1024,
|
||||||
digest: '429d7b3dd2da4989865be6ac07180cc9c3ebdcbbac5028054cddaac870ad520c',
|
digest: '7e024103aaf03553995b09bfb9d6da5ba058ce77f7fff65c56b564017c79f28a',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
name: 'app.css',
|
name: 'app.css',
|
||||||
@@ -42,7 +42,7 @@ const ASSETS = Object.freeze([
|
|||||||
name: 'app.js',
|
name: 'app.js',
|
||||||
field: 'javascript',
|
field: 'javascript',
|
||||||
maximumBytes: 32 * 1024,
|
maximumBytes: 32 * 1024,
|
||||||
digest: '365ccd43ae2aa4b11a0ab3d142cd04e89ec0b96711bef253f63584e8182589ee',
|
digest: '224895776a1df1b4571a8cdb9ce8126861d0284e1fef801e6de3ef31ca96c931',
|
||||||
}),
|
}),
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
|
|||||||
@@ -577,6 +577,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server = await startClusterCopilotConsoleServer({
|
const server = await startClusterCopilotConsoleServer({
|
||||||
|
allowedOperations: operations,
|
||||||
assets,
|
assets,
|
||||||
executor: Object.freeze({
|
executor: Object.freeze({
|
||||||
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
|
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../management-support/pluginPackageManagementClient';
|
} from '../management-support/pluginPackageManagementClient';
|
||||||
import { type ClusterCopilotConsoleAssets } from './assets';
|
import { type ClusterCopilotConsoleAssets } from './assets';
|
||||||
import {
|
import {
|
||||||
|
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||||
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||||
InvalidClusterCopilotConsoleReadRequestError,
|
InvalidClusterCopilotConsoleReadRequestError,
|
||||||
normalizeClusterCopilotConsoleReadRequest,
|
normalizeClusterCopilotConsoleReadRequest,
|
||||||
@@ -43,6 +44,7 @@ export interface ClusterCopilotConsoleExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClusterCopilotConsoleServerOptions {
|
export interface ClusterCopilotConsoleServerOptions {
|
||||||
|
readonly allowedOperations: readonly ClusterCopilotConsoleReadOperation[];
|
||||||
readonly assets: Readonly<ClusterCopilotConsoleAssets>;
|
readonly assets: Readonly<ClusterCopilotConsoleAssets>;
|
||||||
readonly executor: ClusterCopilotConsoleExecutor;
|
readonly executor: ClusterCopilotConsoleExecutor;
|
||||||
readonly networkBoundary?: ClusterCopilotConsoleNetworkBoundary;
|
readonly networkBoundary?: ClusterCopilotConsoleNetworkBoundary;
|
||||||
@@ -69,6 +71,31 @@ export class ClusterCopilotConsoleConfigurationError extends TypeError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
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<ClusterCopilotConsoleReadOperation>(
|
||||||
|
OPTIONAL_OPERATION_GROUPS.flat(),
|
||||||
|
);
|
||||||
const SESSION_DIGEST_DOMAIN = Buffer.from(
|
const SESSION_DIGEST_DOMAIN = Buffer.from(
|
||||||
'qinglong-cluster-copilot-console-session-v1\0',
|
'qinglong-cluster-copilot-console-session-v1\0',
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -219,6 +246,57 @@ function targetPath(
|
|||||||
return request.url === undefined ? null : READ_ROUTES[request.url] ?? null;
|
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<ClusterCopilotConsoleReadOperation>(
|
||||||
|
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<ClusterCopilotConsoleReadOperation>,
|
||||||
|
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<string, unknown>).schema !== CAPABILITIES_REQUEST_SCHEMA
|
||||||
|
) {
|
||||||
|
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function authorize(
|
function authorize(
|
||||||
request: IncomingMessage,
|
request: IncomingMessage,
|
||||||
expectedOrigin: string,
|
expectedOrigin: string,
|
||||||
@@ -331,7 +409,13 @@ function remoteFailure(
|
|||||||
export async function startClusterCopilotConsoleServer(
|
export async function startClusterCopilotConsoleServer(
|
||||||
options: ClusterCopilotConsoleServerOptions,
|
options: ClusterCopilotConsoleServerOptions,
|
||||||
): Promise<Readonly<ClusterCopilotConsoleServer>> {
|
): Promise<Readonly<ClusterCopilotConsoleServer>> {
|
||||||
const optionKeys = ['assets', 'executor', 'port', 'sessionDigest'];
|
const optionKeys = [
|
||||||
|
'allowedOperations',
|
||||||
|
'assets',
|
||||||
|
'executor',
|
||||||
|
'port',
|
||||||
|
'sessionDigest',
|
||||||
|
];
|
||||||
if (Object.hasOwn(options, 'networkBoundary')) {
|
if (Object.hasOwn(options, 'networkBoundary')) {
|
||||||
optionKeys.push('networkBoundary');
|
optionKeys.push('networkBoundary');
|
||||||
}
|
}
|
||||||
@@ -342,6 +426,8 @@ export async function startClusterCopilotConsoleServer(
|
|||||||
'html',
|
'html',
|
||||||
'javascript',
|
'javascript',
|
||||||
]);
|
]);
|
||||||
|
const operations = validatedAllowedOperations(record.allowedOperations);
|
||||||
|
const allowedOperations = new Set(operations);
|
||||||
const networkBoundary =
|
const networkBoundary =
|
||||||
record.networkBoundary === undefined
|
record.networkBoundary === undefined
|
||||||
? 'host-loopback'
|
? 'host-loopback'
|
||||||
@@ -412,16 +498,23 @@ export async function startClusterCopilotConsoleServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capabilitiesRequest =
|
||||||
|
request.method === 'POST' && request.url === CAPABILITIES_PATH;
|
||||||
const operation = targetPath(request);
|
const operation = targetPath(request);
|
||||||
if (
|
if (
|
||||||
!hostMatches ||
|
!hostMatches ||
|
||||||
operation === null ||
|
(!capabilitiesRequest && operation === null) ||
|
||||||
!authorize(request, expectedOrigin, sessionDigest)
|
!authorize(request, expectedOrigin, sessionDigest)
|
||||||
) {
|
) {
|
||||||
sendJson(response, 404, Object.freeze({ code: 'not_found' }));
|
sendJson(response, 404, Object.freeze({ code: 'not_found' }));
|
||||||
request.resume();
|
request.resume();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!capabilitiesRequest && !allowedOperations.has(operation!)) {
|
||||||
|
sendJson(response, 404, Object.freeze({ code: 'not_found' }));
|
||||||
|
request.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests) {
|
if (inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests) {
|
||||||
sendJson(
|
sendJson(
|
||||||
response,
|
response,
|
||||||
@@ -439,6 +532,41 @@ export async function startClusterCopilotConsoleServer(
|
|||||||
inFlight += 1;
|
inFlight += 1;
|
||||||
try {
|
try {
|
||||||
const body = await readJsonBody(request);
|
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);
|
const normalized = normalizeClusterCopilotConsoleReadRequest(body);
|
||||||
if (normalized.operation !== operation) {
|
if (normalized.operation !== operation) {
|
||||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
loadClusterCopilotConsoleAssets,
|
loadClusterCopilotConsoleAssets,
|
||||||
} = require('../dist/copilot-console/assets.js');
|
} = require('../dist/copilot-console/assets.js');
|
||||||
const {
|
const {
|
||||||
|
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||||
CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
clusterCopilotConsoleClientCommand,
|
clusterCopilotConsoleClientCommand,
|
||||||
clusterCopilotConsoleProjectReadPath,
|
clusterCopilotConsoleProjectReadPath,
|
||||||
@@ -26,6 +27,18 @@ const {
|
|||||||
} = require('../dist/copilot-console/server.js');
|
} = require('../dist/copilot-console/server.js');
|
||||||
|
|
||||||
const moduleDirectory = resolve(__dirname, '../dist/copilot-console');
|
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') {
|
function target(operation = 'inspect') {
|
||||||
return {
|
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 token = randomBytes(32).toString('base64url');
|
||||||
const server = await startClusterCopilotConsoleServer({
|
const server = await startClusterCopilotConsoleServer({
|
||||||
|
allowedOperations,
|
||||||
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
||||||
executor: { execute },
|
executor: { execute },
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -501,6 +518,7 @@ test('allows only an explicit fixed-port container listener behind host loopback
|
|||||||
const token = randomBytes(32).toString('base64url');
|
const token = randomBytes(32).toString('base64url');
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
startClusterCopilotConsoleServer({
|
startClusterCopilotConsoleServer({
|
||||||
|
allowedOperations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||||
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
||||||
executor: { execute: async () => inspection() },
|
executor: { execute: async () => inspection() },
|
||||||
networkBoundary: 'container-published-loopback',
|
networkBoundary: 'container-published-loopback',
|
||||||
@@ -510,6 +528,7 @@ test('allows only an explicit fixed-port container listener behind host loopback
|
|||||||
ClusterCopilotConsoleConfigurationError,
|
ClusterCopilotConsoleConfigurationError,
|
||||||
);
|
);
|
||||||
const server = await startClusterCopilotConsoleServer({
|
const server = await startClusterCopilotConsoleServer({
|
||||||
|
allowedOperations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||||
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
|
||||||
executor: { execute: async () => inspection() },
|
executor: { execute: async () => inspection() },
|
||||||
networkBoundary: 'container-published-loopback',
|
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);
|
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) => {
|
test('keeps the Cluster credential server-side and forwards one exact inspect', async (t) => {
|
||||||
const commands = [];
|
const commands = [];
|
||||||
const { server, headers } = await fixture(async (command) => {
|
const { server, headers } = await fixture(async (command) => {
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
"'/api/v1/run-management/cancellation-status': 'run_cancellation_status'",
|
"'/api/v1/run-management/cancellation-status': 'run_cancellation_status'",
|
||||||
"'/api/v1/worker-management/workers': 'worker_list'",
|
"'/api/v1/worker-management/workers': 'worker_list'",
|
||||||
"'/api/v1/package-management/installations': 'package_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/run-list': 'run_list'",
|
||||||
"'/api/v1/observe/task-list': 'task_list'",
|
"'/api/v1/observe/task-list': 'task_list'",
|
||||||
"'/api/v1/observe/workflow-list': 'workflow_list'",
|
"'/api/v1/observe/workflow-list': 'workflow_list'",
|
||||||
@@ -181,6 +185,7 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'runManagementAuthority: runManagementAuthority',
|
'runManagementAuthority: runManagementAuthority',
|
||||||
'workerManagementAuthority: workerManagementAuthority',
|
'workerManagementAuthority: workerManagementAuthority',
|
||||||
'packageManagementAuthority: packageManagementAuthority',
|
'packageManagementAuthority: packageManagementAuthority',
|
||||||
|
'allowedOperations: operations',
|
||||||
'mutation: false',
|
'mutation: false',
|
||||||
]);
|
]);
|
||||||
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
|
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
|
||||||
@@ -246,6 +251,8 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'该只读面没有 rearm',
|
'该只读面没有 rearm',
|
||||||
'模型文本是不可信内容',
|
'模型文本是不可信内容',
|
||||||
'导出脱敏包',
|
'导出脱敏包',
|
||||||
|
'id="authority-summary"',
|
||||||
|
'QingLong 3.0 incubation / D-377',
|
||||||
'/evidence-bundle.js',
|
'/evidence-bundle.js',
|
||||||
]);
|
]);
|
||||||
expectFragments(ASSET_ROOT + '/app.js', [
|
expectFragments(ASSET_ROOT + '/app.js', [
|
||||||
@@ -256,6 +263,9 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'measureClusterConsoleEvidenceRecord(record)',
|
'measureClusterConsoleEvidenceRecord(record)',
|
||||||
'createClusterConsoleEvidenceBundle',
|
'createClusterConsoleEvidenceBundle',
|
||||||
'URL.revokeObjectURL(objectUrl)',
|
'URL.revokeObjectURL(objectUrl)',
|
||||||
|
"fetch('/api/v1/session/capabilities'",
|
||||||
|
'applyCapabilities(capabilities)',
|
||||||
|
'allowedOperations.has(operation)',
|
||||||
"sessionToken = ''",
|
"sessionToken = ''",
|
||||||
]);
|
]);
|
||||||
rejectFragments(ASSET_ROOT + '/app.js', [
|
rejectFragments(ASSET_ROOT + '/app.js', [
|
||||||
@@ -312,6 +322,9 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'thirteen exact operations',
|
'thirteen exact operations',
|
||||||
'available vocabulary to sixteen',
|
'available vocabulary to sixteen',
|
||||||
'maximum vocabulary of twenty operations',
|
'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_RUN_MANAGEMENT=enabled',
|
||||||
'QL3_COPILOT_CONSOLE_WORKER_MANAGEMENT=enabled',
|
'QL3_COPILOT_CONSOLE_WORKER_MANAGEMENT=enabled',
|
||||||
'QL3_COPILOT_CONSOLE_PACKAGE_MANAGEMENT=enabled',
|
'QL3_COPILOT_CONSOLE_PACKAGE_MANAGEMENT=enabled',
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 128,
|
sourceFiles: 129,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 61,
|
rootSourceLines: 61,
|
||||||
nestedSourceFiles: 127,
|
nestedSourceFiles: 128,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user