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
@@ -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) => {