feat(ql3): fence console capabilities by session

This commit is contained in:
whyour
2026-08-20 15:15:42 +08:00
parent ffa4b4e7cb
commit c0ef9a969e
12 changed files with 454 additions and 19 deletions
@@ -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.
+16
View File
@@ -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/逐包测试单次退出 0package 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 modulesApplication+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,独立
@@ -0,0 +1,71 @@
# ADR-0470Console 会话级能力发现与服务端操作围栏
- 状态:Accepted
- 日期:2026-08-20
- 关联 RFCQL-RFC-0001 D-377、D-376、D-375、D-107
- 关联 ADRADR-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/passedworkspace 仍为 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` bytesMCP 为 `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` 只作为相邻既有基线,数据库语义变化时必须重跑。
+1
View File
@@ -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 |
## 规则
@@ -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 = '';
@@ -24,7 +24,7 @@
<div class="boundary" aria-label="当前权限边界">
<span class="boundary-dot" aria-hidden="true"></span>
<span>本机只读 BFF</span>
<strong>Run · Task · Workflow · Worker · Package · Copilot</strong>
<strong id="authority-summary">等待会话能力</strong>
</div>
</header>
@@ -191,7 +191,7 @@
<footer>
<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>
</div>
</body>
@@ -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);
@@ -577,6 +577,7 @@ async function main(): Promise<void> {
}
const server = await startClusterCopilotConsoleServer({
allowedOperations: operations,
assets,
executor: Object.freeze({
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
@@ -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<ClusterCopilotConsoleAssets>;
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<ClusterCopilotConsoleReadOperation>(
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<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(
request: IncomingMessage,
expectedOrigin: string,
@@ -331,7 +409,13 @@ function remoteFailure(
export async function startClusterCopilotConsoleServer(
options: ClusterCopilotConsoleServerOptions,
): Promise<Readonly<ClusterCopilotConsoleServer>> {
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();
@@ -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) => {
@@ -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',
+2 -2
View File
@@ -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',
},