feat(ql3): distribute copilot console via signed admin image

This commit is contained in:
whyour
2026-08-16 05:14:17 +08:00
parent c4a1238a92
commit fba8dfb602
22 changed files with 1176 additions and 29 deletions
+7 -4
View File
@@ -16,10 +16,13 @@ Host/Origin, no-store responses and a closed CSP, renders model text only via
`textContent`, and keeps diagnose/cancel, polling, cache, WebSocket,
ServiceWorker and legacy session authority absent.
The reviewed operator-workstation setup, private-file ceremony, preflight and
session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Do not expose the Console
through a container port mapping, Kubernetes workload or shared network.
The reviewed operator-workstation setup, private-file ceremony, release
verification, preflight and session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
loopback directly. The signed Admin OCI also carries an exact launcher which
uses a container-internal listener only with a fixed publication on host
`127.0.0.1`; arbitrary port mappings, Kubernetes workloads and shared-network
listeners remain forbidden.
The admin role can append Identity/API Credential mutations and their security
audit in one serializable transaction, and can perform bounded read-only audit
@@ -18,8 +18,9 @@ const USAGE = [
'Usage:',
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
'',
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
'The browser session key remains in a separate owner-private 0600 file.',
].join('\n');
@@ -27,6 +28,9 @@ interface ClusterCopilotConsoleCliArguments {
readonly check: boolean;
readonly configFile: string;
readonly credentialFile: string;
readonly networkBoundary:
| 'host-loopback'
| 'container-published-loopback';
readonly sessionFile: string;
readonly port: number;
}
@@ -71,6 +75,7 @@ export function parseClusterCopilotConsoleCliArguments(
let sessionFile: string | undefined;
let port = 0;
let portSeen = false;
let containerPublishedLoopback = false;
for (let index = 0; index < argv.length; ) {
const current = argv[index];
if (current === '--check' && !check) {
@@ -78,6 +83,14 @@ export function parseClusterCopilotConsoleCliArguments(
index += 1;
continue;
}
if (
current === '--container-published-loopback' &&
!containerPublishedLoopback
) {
containerPublishedLoopback = true;
index += 1;
continue;
}
const config = argumentValue(argv, index, '--config');
if (config) {
if (configFile !== undefined) return usageFailure();
@@ -121,7 +134,8 @@ export function parseClusterCopilotConsoleCliArguments(
configFile === undefined ||
credentialFile === undefined ||
sessionFile === undefined ||
(check && port !== 0)
(containerPublishedLoopback && port === 0) ||
(!containerPublishedLoopback && check && port !== 0)
) {
return usageFailure();
}
@@ -129,6 +143,9 @@ export function parseClusterCopilotConsoleCliArguments(
check,
configFile,
credentialFile,
networkBoundary: containerPublishedLoopback
? 'container-published-loopback'
: 'host-loopback',
sessionFile,
port,
});
@@ -178,7 +195,8 @@ async function main(): Promise<void> {
component: 'qinglong3-cluster-copilot-console',
event: 'preflight_checked',
ready: readiness.ready,
listenAddress: '127.0.0.1',
networkBoundary: parsed.networkBoundary,
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
@@ -203,6 +221,7 @@ async function main(): Promise<void> {
});
},
}),
networkBoundary: parsed.networkBoundary,
port: parsed.port,
sessionDigest,
});
@@ -213,7 +232,8 @@ async function main(): Promise<void> {
component: 'qinglong3-cluster-copilot-console',
event: 'started',
origin: server.origin,
listenAddress: '127.0.0.1',
networkBoundary: parsed.networkBoundary,
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
@@ -39,10 +39,15 @@ export interface ClusterCopilotConsoleExecutor {
export interface ClusterCopilotConsoleServerOptions {
readonly assets: Readonly<ClusterCopilotConsoleAssets>;
readonly executor: ClusterCopilotConsoleExecutor;
readonly networkBoundary?: ClusterCopilotConsoleNetworkBoundary;
readonly port: number;
readonly sessionDigest: Buffer;
}
export type ClusterCopilotConsoleNetworkBoundary =
| 'host-loopback'
| 'container-published-loopback';
export interface ClusterCopilotConsoleServer {
readonly origin: string;
close(): Promise<void>;
@@ -297,13 +302,16 @@ function remoteFailure(
export async function startClusterCopilotConsoleServer(
options: ClusterCopilotConsoleServerOptions,
): Promise<Readonly<ClusterCopilotConsoleServer>> {
const record = exactObject(options, [
'assets',
'executor',
'port',
'sessionDigest',
]);
const optionKeys = ['assets', 'executor', 'port', 'sessionDigest'];
if (Object.hasOwn(options, 'networkBoundary')) {
optionKeys.push('networkBoundary');
}
const record = exactObject(options, optionKeys);
const assets = exactObject(record.assets, ['css', 'html', 'javascript']);
const networkBoundary =
record.networkBoundary === undefined
? 'host-loopback'
: record.networkBoundary;
if (
typeof assets.html !== 'string' ||
assets.html.length < 1 ||
@@ -317,6 +325,10 @@ export async function startClusterCopilotConsoleServer(
!Number.isSafeInteger(record.port) ||
((record.port as number) !== 0 &&
((record.port as number) < 1_024 || (record.port as number) > 65_535)) ||
(networkBoundary !== 'host-loopback' &&
networkBoundary !== 'container-published-loopback') ||
(networkBoundary === 'container-published-loopback' &&
(record.port as number) === 0) ||
!Buffer.isBuffer(record.sessionDigest) ||
(record.sessionDigest as Buffer).byteLength !== 32
) {
@@ -324,6 +336,8 @@ export async function startClusterCopilotConsoleServer(
}
const sessionDigest = Buffer.from(record.sessionDigest as Buffer);
const executor = record.executor as ClusterCopilotConsoleExecutor;
const listenAddress =
networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0';
let expectedOrigin = '';
let inFlight = 0;
let closed = false;
@@ -461,7 +475,7 @@ export async function startClusterCopilotConsoleServer(
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(record.port as number, '127.0.0.1', () => {
server.listen(record.port as number, listenAddress, () => {
server.off('error', reject);
resolve();
});
@@ -1,6 +1,6 @@
const assert = require('node:assert/strict');
const { randomBytes } = require('node:crypto');
const { request: httpRequest } = require('node:http');
const { createServer, request: httpRequest } = require('node:http');
const { mkdtemp, mkdir, cp, writeFile } = require('node:fs/promises');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
@@ -19,6 +19,7 @@ const {
normalizeClusterCopilotConsoleReadRequest,
} = require('../dist/copilot-console/contracts.js');
const {
ClusterCopilotConsoleConfigurationError,
clusterCopilotConsoleSessionDigest,
startClusterCopilotConsoleServer,
} = require('../dist/copilot-console/server.js');
@@ -165,6 +166,20 @@ async function fixture(execute = async () => inspection()) {
};
}
async function unusedPort() {
const probe = createServer();
await new Promise((resolve, reject) => {
probe.once('error', reject);
probe.listen(0, '127.0.0.1', resolve);
});
const address = probe.address();
assert.notEqual(typeof address, 'string');
assert.notEqual(address, null);
const port = address.port;
await new Promise((resolve) => probe.close(resolve));
return port;
}
test('normalizes only the two read operations into the shared client contract', () => {
assert.deepEqual(
clusterCopilotConsoleClientCommand(
@@ -244,6 +259,30 @@ test('serves an immutable same-origin shell with a closed browser policy', async
assert.equal(javascript.headers['content-type'], 'text/javascript; charset=utf-8');
});
test('allows only an explicit fixed-port container listener behind host loopback publication', async (t) => {
const token = randomBytes(32).toString('base64url');
await assert.rejects(
startClusterCopilotConsoleServer({
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
executor: { execute: async () => inspection() },
networkBoundary: 'container-published-loopback',
port: 0,
sessionDigest: clusterCopilotConsoleSessionDigest(token),
}),
ClusterCopilotConsoleConfigurationError,
);
const server = await startClusterCopilotConsoleServer({
assets: loadClusterCopilotConsoleAssets(moduleDirectory),
executor: { execute: async () => inspection() },
networkBoundary: 'container-published-loopback',
port: await unusedPort(),
sessionDigest: clusterCopilotConsoleSessionDigest(token),
});
t.after(() => server.close());
assert.match(server.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.equal((await request(server.origin)).statusCode, 200);
});
test('keeps the Cluster credential server-side and forwards one exact inspect', async (t) => {
const commands = [];
const { server, headers } = await fixture(async (command) => {
@@ -163,8 +163,9 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
'Usage:',
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
'',
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
'The browser session key remains in a separate owner-private 0600 file.',
].join('\n');
assert.deepEqual(await runCli(['--help']), {
@@ -209,7 +210,8 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
component: 'qinglong3-cluster-copilot-console',
event: 'preflight_checked',
ready: true,
listenAddress: '127.0.0.1',
networkBoundary: 'host-loopback',
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
@@ -249,6 +251,8 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.deepEqual(started.operations, ['inspect', 'output']);
assert.equal(started.mutation, false);
assert.equal(started.networkBoundary, 'host-loopback');
assert.equal(started.publishedHostAddress, '127.0.0.1');
const shell = await get(started.origin);
assert.equal(shell.statusCode, 200);
assert.match(shell.body, /Cluster field console/);
@@ -259,3 +263,19 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
});
assert.deepEqual(result, { status: 0, signal: null });
});
test('container mode requires an explicit publish port before any authority read', async () => {
const result = await runCli([
'--container-published-loopback',
'--config',
'/private/client.json',
'--credential',
'/private/credential',
'--session',
'/private/session',
]);
assert.equal(result.status, 64);
assert.equal(result.stdout, '');
assert.match(result.stderr, /container-published-loopback/);
assert.doesNotMatch(result.stderr, /\/private\//);
});