feat(ql3): add explicit cluster observation console

This commit is contained in:
whyour
2026-08-16 06:09:54 +08:00
parent fba8dfb602
commit 7955d55629
20 changed files with 1881 additions and 877 deletions
+10 -8
View File
@@ -1,20 +1,22 @@
# `@qinglong/cluster-admin`
This private QingLong 3.0 package owns explicit cluster operations and the
bounded Cluster Copilot MCP and Console product surfaces.
bounded Cluster Copilot MCP plus Cluster field Console product surfaces.
Database/Kubernetes administration remains short-lived and requires distinct
purpose-bound authority. The MCP subpath has only the remote API client; the
Console is a loopback-only read BFF serving digest-bound static assets. Neither
opens database or Kubernetes authority, enters the legacy 2.x Web application,
or resides in `cluster-control`.
The Console accepts only `inspect` and explicit `output` reads. Its Cluster
API credential stays in a canonical owner-private file and is reread for each
upstream request; browser JavaScript receives only a separate session token
which cannot call Cluster APIs. It binds `127.0.0.1`, enforces exact
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 Console accepts thirteen exact Run, Task, Workflow and Copilot reads. The
browser cannot provide an upstream path or HTTP method, and every list page and
detail/evidence read requires an explicit click. Its Cluster API credential
stays in a canonical owner-private file and is reread for each upstream
request; browser JavaScript receives only a separate session token which cannot
call Cluster APIs. It binds `127.0.0.1`, enforces exact Host/Origin, no-store
responses and a closed CSP, renders all returned data only via `textContent`,
and keeps start/diagnose/cancel, polling, cache, WebSocket, ServiceWorker and
legacy session authority absent.
The reviewed operator-workstation setup, private-file ceremony, release
verification, preflight and session lifecycle are documented in
@@ -1,21 +1,18 @@
:root {
--paper: #f3f6f1;
--paper-deep: #e7ede6;
--ink: #17231d;
--muted: #607067;
--line: #cbd5cc;
--moss: #2f654d;
--moss-soft: #dce9df;
--amber: #c98724;
--plum: #69556d;
--rust: #a84f38;
--white: #fbfdf9;
--shadow: 0 24px 70px rgba(31, 52, 40, 0.12);
font-family:
"Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC",
"Hiragino Sans GB", sans-serif;
--porcelain: #eef3f5;
--paper: #f8faf9;
--ink: #152329;
--muted: #607079;
--line: #bdcbd0;
--cyan: #087f8c;
--cyan-soft: #d7ecee;
--signal: #df6538;
--violet: #655f87;
--error: #a13f32;
--white: #ffffff;
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', sans-serif;
color: var(--ink);
background: var(--paper);
background: var(--porcelain);
font-synthesis: none;
}
@@ -23,19 +20,25 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html {
min-width: 320px;
background:
linear-gradient(90deg, rgba(47, 101, 77, 0.035) 1px, transparent 1px)
0 0 / 24px 24px,
var(--paper);
background: linear-gradient(
90deg,
rgba(8, 127, 140, 0.035) 1px,
transparent 1px
)
0 0 / 28px 28px,
var(--porcelain);
}
body {
margin: 0;
min-height: 100vh;
}
button,
input {
font: inherit;
@@ -43,15 +46,13 @@ input {
button {
border: 1px solid var(--ink);
border-radius: 3px;
padding: 0.78rem 1rem;
border-radius: 2px;
padding: 0.72rem 0.85rem;
color: var(--ink);
background: transparent;
cursor: pointer;
transition:
transform 140ms ease,
background-color 140ms ease,
color 140ms ease;
transition: transform 130ms ease, background-color 130ms ease,
color 130ms ease;
}
button:hover:not(:disabled) {
@@ -59,22 +60,19 @@ button:hover:not(:disabled) {
background: var(--ink);
color: var(--white);
}
button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
button.primary {
background: var(--moss);
border-color: var(--moss);
border-color: var(--cyan);
background: var(--cyan);
color: var(--white);
}
button:focus-visible,
input:focus-visible,
pre:focus-visible {
outline: 3px solid color-mix(in srgb, var(--amber) 72%, white);
outline: 3px solid color-mix(in srgb, var(--signal) 72%, white);
outline-offset: 3px;
}
@@ -83,15 +81,14 @@ input {
border: 0;
border-bottom: 1px solid var(--ink);
border-radius: 0;
padding: 0.72rem 0;
padding: 0.64rem 0;
color: var(--ink);
background: transparent;
}
input::placeholder {
color: #91a097;
color: #8a9ba2;
}
.skip-link {
position: fixed;
left: 1rem;
@@ -102,477 +99,395 @@ input::placeholder {
background: var(--ink);
color: white;
}
.skip-link:focus {
transform: translateY(0);
}
.shell {
width: min(1440px, 100%);
width: min(1500px, 100%);
margin: 0 auto;
min-height: 100vh;
padding: 2rem clamp(1rem, 3vw, 3.5rem) 1.25rem;
padding: 1.6rem clamp(0.8rem, 3vw, 3rem) 1rem;
display: flex;
flex-direction: column;
}
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
padding-bottom: 1.6rem;
padding-bottom: 1.4rem;
border-bottom: 1px solid var(--ink);
}
.brand-lockup {
display: flex;
align-items: center;
gap: 1.2rem;
gap: 1.1rem;
}
.brand-mark {
display: grid;
place-items: center;
width: 3.15rem;
height: 3.15rem;
width: 3rem;
height: 3rem;
border: 1px solid var(--ink);
border-radius: 50%;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
clip-path: polygon(0 0, 82% 0, 100% 18%, 100% 100%, 18% 100%, 0 82%);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-weight: 800;
letter-spacing: -0.09em;
letter-spacing: -0.08em;
}
.eyebrow {
margin: 0 0 0.42rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.69rem;
margin: 0 0 0.38rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.67rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
max-width: 18ch;
font-size: clamp(1.55rem, 3vw, 3rem);
line-height: 0.98;
letter-spacing: -0.055em;
max-width: 20ch;
font-size: clamp(1.5rem, 3vw, 2.85rem);
line-height: 1;
letter-spacing: -0.05em;
}
h2 {
margin-bottom: 0.7rem;
font-size: clamp(1.45rem, 2vw, 2.15rem);
margin-bottom: 0.65rem;
font-size: clamp(1.35rem, 2vw, 2rem);
letter-spacing: -0.035em;
}
h3 {
letter-spacing: -0.025em;
}
.boundary {
min-width: 11rem;
min-width: 13rem;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.22rem 0.52rem;
gap: 0.2rem 0.5rem;
align-items: center;
font-size: 0.76rem;
font-size: 0.74rem;
}
.boundary strong {
grid-column: 2;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-weight: 500;
color: var(--muted);
}
.boundary-dot {
width: 0.65rem;
height: 0.65rem;
width: 0.62rem;
height: 0.62rem;
border-radius: 50%;
background: var(--moss);
box-shadow: 0 0 0 5px var(--moss-soft);
background: var(--cyan);
box-shadow: 0 0 0 5px var(--cyan-soft);
}
.workspace {
flex: 1;
display: grid;
grid-template-columns: minmax(18rem, 0.82fr) minmax(28rem, 1.4fr);
min-height: 680px;
grid-template-columns: minmax(20rem, 0.8fr) minmax(30rem, 1.45fr);
min-height: 720px;
border-bottom: 1px solid var(--ink);
}
.control-deck,
.evidence-panel {
padding: clamp(1.5rem, 3vw, 3.25rem);
padding: clamp(1.35rem, 3vw, 2.8rem);
}
.control-deck {
border-right: 1px solid var(--ink);
background: rgba(251, 253, 249, 0.72);
background: rgba(248, 250, 249, 0.84);
}
.section-heading > p:last-child {
max-width: 44ch;
line-height: 1.7;
max-width: 46ch;
line-height: 1.65;
color: var(--muted);
}
.session-gate {
margin: 2.5rem 0 2rem;
padding: 1.1rem;
border-left: 3px solid var(--plum);
background: color-mix(in srgb, var(--plum) 8%, var(--white));
margin: 2rem 0;
padding: 1rem;
border-left: 3px solid var(--violet);
background: color-mix(in srgb, var(--violet) 7%, var(--white));
}
.inline-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.8rem;
gap: 0.7rem;
}
.field-note {
margin: 0.75rem 0 0;
font-size: 0.78rem;
line-height: 1.55;
color: var(--muted);
}
.target-form {
display: grid;
gap: 0.45rem;
}
.target-form label,
.field-label,
.session-gate label {
margin-top: 0.9rem;
font-size: 0.78rem;
display: block;
margin-top: 0.8rem;
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.025em;
}
.actions {
.field-note {
margin: 0.65rem 0 0;
font-size: 0.76rem;
line-height: 1.5;
color: var(--muted);
}
.mode-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, 1fr);
margin: 1.8rem 0 1.2rem;
border-bottom: 1px solid var(--line);
}
.mode-tab {
border: 0;
border-bottom: 3px solid transparent;
border-radius: 0;
padding-inline: 0.3rem;
color: var(--muted);
}
.mode-tab.active {
border-bottom-color: var(--cyan);
color: var(--ink);
font-weight: 760;
}
.mode-tab:hover:not(:disabled) {
transform: none;
background: var(--cyan-soft);
color: var(--ink);
}
.mode-panel {
display: grid;
gap: 0.7rem;
}
.control-group {
display: grid;
gap: 0.65rem;
margin-top: 1.7rem;
padding: 0.9rem 0 1.05rem;
border-bottom: 1px solid var(--line);
}
.control-title {
display: flex;
align-items: baseline;
gap: 0.55rem;
}
.control-title span {
color: var(--cyan);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.67rem;
}
.button-pair,
.button-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.button-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trust-note {
margin-top: 2.4rem;
padding-top: 1rem;
margin-top: 2rem;
padding-top: 0.9rem;
border-top: 1px solid var(--line);
}
.trust-note span {
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.68rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--plum);
color: var(--violet);
}
.trust-note p {
margin: 0.45rem 0 0;
font-size: 0.8rem;
margin: 0.4rem 0 0;
font-size: 0.78rem;
line-height: 1.55;
color: var(--muted);
}
.evidence-panel {
position: relative;
background: var(--paper-deep);
background: var(--paper);
}
.evidence-header,
.output-heading {
.evidence-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.status-chip {
border: 1px solid currentColor;
border-radius: 999px;
padding: 0.35rem 0.64rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.68rem;
padding: 0.33rem 0.62rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.66rem;
color: var(--muted);
}
.status-chip[data-tone="running"] {
color: var(--amber);
.status-chip[data-tone='busy'] {
color: var(--signal);
}
.status-chip[data-tone="success"] {
color: var(--moss);
.status-chip[data-tone='success'] {
color: var(--cyan);
}
.status-chip[data-tone="failed"] {
color: var(--rust);
.status-chip[data-tone='failed'] {
color: var(--error);
}
.empty-state {
min-height: 450px;
min-height: 480px;
display: grid;
place-content: center;
justify-items: center;
text-align: center;
}
.empty-state p {
max-width: 38ch;
line-height: 1.65;
color: var(--muted);
}
.empty-glyph {
width: 12rem;
height: 7rem;
margin-bottom: 2rem;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 1.3rem;
border-bottom: 1px solid var(--ink);
}
.empty-glyph span {
width: 1px;
background: var(--ink);
transform-origin: bottom;
animation: signal 2.4s ease-in-out infinite;
}
.empty-glyph span:nth-child(1) {
height: 35%;
}
.empty-glyph span:nth-child(2) {
height: 82%;
animation-delay: 180ms;
}
.empty-glyph span:nth-child(3) {
height: 54%;
animation-delay: 360ms;
}
@keyframes signal {
0%,
100% {
transform: scaleY(0.55);
opacity: 0.42;
}
45% {
transform: scaleY(1);
opacity: 1;
}
}
.trace-rail {
list-style: none;
margin: 2.2rem 0;
padding: 0;
.evidence-rail {
width: min(25rem, 80%);
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
align-items: center;
margin-bottom: 2.2rem;
}
.trace-rail li {
.evidence-rail i {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
min-height: 5rem;
}
.trace-rail li:not(:last-child)::after {
content: "";
position: absolute;
top: 0.42rem;
left: 0.45rem;
right: -0.45rem;
height: 1px;
background: var(--moss);
background: var(--line);
}
.trace-node {
.evidence-rail i::before {
content: '';
position: absolute;
left: 0;
top: -0.35rem;
width: 0.7rem;
height: 0.7rem;
border: 1px solid var(--cyan);
background: var(--paper);
transform: rotate(45deg);
}
.ledger {
margin: 0;
padding: 1rem 0 0;
list-style: none;
counter-reset: evidence;
}
.ledger-entry {
position: relative;
z-index: 1;
width: 0.9rem;
height: 0.9rem;
border: 3px solid var(--paper-deep);
border-radius: 50%;
background: var(--moss);
box-shadow: 0 0 0 1px var(--moss);
padding: 1.1rem 0 1.2rem 3.5rem;
border-bottom: 1px solid var(--line);
counter-increment: evidence;
}
.trace-rail small,
.fact-grid dt,
.output-meta dt {
display: block;
margin-bottom: 0.42rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
.ledger-entry::before {
content: counter(evidence, decimal-leading-zero);
position: absolute;
left: 0;
top: 1.15rem;
width: 2.3rem;
color: var(--cyan);
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.75rem;
}
.ledger-entry::after {
content: '';
position: absolute;
left: 2.35rem;
top: 1.05rem;
bottom: 1rem;
width: 1px;
background: var(--line);
}
.ledger-entry header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
}
.ledger-entry h3 {
margin-bottom: 0.35rem;
font-size: 1rem;
}
.ledger-entry time {
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.65rem;
letter-spacing: 0.11em;
color: var(--muted);
}
.trace-rail strong {
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.fact-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--ink);
border-left: 1px solid var(--ink);
}
.fact-grid div {
min-height: 6.4rem;
padding: 1rem;
border-right: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
}
.fact-grid dd,
.output-meta dd {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.output-panel {
margin-top: 2rem;
padding: 1.2rem;
border: 1px solid var(--plum);
background: var(--white);
box-shadow: var(--shadow);
}
.output-heading span {
border: 1px solid var(--plum);
padding: 0.3rem 0.55rem;
font-size: 0.66rem;
color: var(--plum);
}
.output-panel pre {
.ledger-entry pre {
max-height: 28rem;
overflow: auto;
margin: 1rem 0;
padding: 1rem;
border-left: 3px solid var(--plum);
margin: 0.65rem 0 0;
padding: 0.9rem;
border-left: 2px solid var(--cyan);
background: #e9f0f1;
color: #203138;
white-space: pre-wrap;
word-break: break-word;
font: 0.82rem/1.7 ui-monospace, "SFMono-Regular", Consolas, monospace;
background: #f1eef2;
overflow-wrap: anywhere;
font: 0.72rem/1.55 ui-monospace, 'SFMono-Regular', Consolas, monospace;
}
.output-meta {
display: grid;
grid-template-columns: 0.7fr 0.7fr 1.6fr;
gap: 1rem;
margin: 0;
}
.message {
min-height: 1.4rem;
margin-top: 1.2rem;
font-size: 0.82rem;
margin-top: 1rem;
font-size: 0.8rem;
color: var(--muted);
}
.message[data-tone="error"] {
color: var(--rust);
.message[data-tone='error'] {
color: var(--error);
}
footer {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-top: 1rem;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
padding-top: 0.9rem;
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.65rem;
color: var(--muted);
}
[hidden] {
display: none !important;
}
@media (max-width: 860px) {
@media (max-width: 900px) {
.masthead,
footer {
flex-direction: column;
}
.boundary {
align-self: flex-start;
}
.workspace {
grid-template-columns: 1fr;
}
.control-deck {
border-right: 0;
border-bottom: 1px solid var(--ink);
}
.trace-rail {
grid-template-columns: 1fr;
gap: 1rem;
}
.trace-rail li {
min-height: 3rem;
}
.trace-rail li:not(:last-child)::after {
left: 0.42rem;
right: auto;
top: 0.8rem;
bottom: -1.45rem;
width: 1px;
height: auto;
.empty-state {
min-height: 300px;
}
}
@media (max-width: 520px) {
.shell {
padding-inline: 0.75rem;
padding-inline: 0.65rem;
}
.brand-lockup {
align-items: flex-start;
}
.brand-mark {
flex: 0 0 auto;
width: 2.7rem;
height: 2.7rem;
display: none;
}
.inline-control,
.button-pair,
.button-grid {
grid-template-columns: 1fr;
}
.mode-tabs {
font-size: 0.78rem;
}
.control-deck,
.evidence-panel {
padding: 1.25rem;
padding: 1.1rem;
}
.inline-control,
.actions,
.fact-grid,
.output-meta {
grid-template-columns: 1fr;
.ledger-entry {
padding-left: 2.8rem;
}
.ledger-entry::after {
left: 1.9rem;
}
}
@@ -582,7 +497,5 @@ footer {
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@@ -1,190 +1,297 @@
"use strict";
'use strict';
(function () {
const commandSchema = "qinglong/cluster-copilot-console-read-request@v1";
const sessionForm = document.getElementById("session-form");
const sessionInput = document.getElementById("session-token");
const targetForm = document.getElementById("target-form");
const inspectButton = document.getElementById("inspect-button");
const outputButton = document.getElementById("output-button");
const emptyState = document.getElementById("empty-state");
const resultView = document.getElementById("result-view");
const outputPanel = document.getElementById("output-panel");
const outputText = document.getElementById("output-text");
const message = document.getElementById("message");
const statusChip = document.getElementById("status-chip");
let sessionToken = "";
let currentTarget = null;
const schema = 'qinglong/cluster-copilot-console-read-request@v1';
const routes = Object.freeze({
inspect: '/api/v1/copilot/inspect',
output: '/api/v1/copilot/output',
run_list: '/api/v1/observe/run-list',
run_read: '/api/v1/observe/run',
run_event_list: '/api/v1/observe/run-events',
run_step_list: '/api/v1/observe/run-steps',
task_list: '/api/v1/observe/task-list',
task_read: '/api/v1/observe/task',
workflow_list: '/api/v1/observe/workflow-list',
workflow_run_list: '/api/v1/observe/workflow-run-list',
workflow_run_read: '/api/v1/observe/workflow-run',
workflow_event_list: '/api/v1/observe/workflow-events',
workflow_step_list: '/api/v1/observe/workflow-steps',
});
const labels = Object.freeze({
inspect: 'Copilot 诊断状态',
output: 'Copilot 诊断内容',
run_list: 'Run 目录',
run_read: 'Run 详情',
run_event_list: 'Run Events',
run_step_list: 'Run Steps',
task_list: 'Task 目录',
task_read: 'Task 当前修订',
workflow_list: 'Workflow 目录',
workflow_run_list: 'Workflow Run 目录',
workflow_run_read: 'Workflow Run 详情',
workflow_event_list: 'Workflow Run Events',
workflow_step_list: 'Workflow Run Steps',
});
const sessionForm = document.getElementById('session-form');
const sessionInput = document.getElementById('session-token');
const controls = document.getElementById('console-controls');
const ledger = document.getElementById('ledger');
const emptyState = document.getElementById('empty-state');
const message = document.getElementById('message');
const statusChip = document.getElementById('status-chip');
let sessionToken = '';
let busy = false;
const setText = function (id, value) {
document.getElementById(id).textContent =
value === null || value === undefined || value === "" ? "—" : String(value);
const value = function (id) {
return document.getElementById(id).value.trim();
};
const setMessage = function (value, tone) {
message.textContent = value;
message.dataset.tone = tone || "neutral";
const requestId = function () {
return 'console-' + crypto.randomUUID();
};
const dateTime = function (value) {
if (!Number.isSafeInteger(value)) return "—";
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "medium",
}).format(new Date(value));
const setMessage = function (text, tone) {
message.textContent = text;
message.dataset.tone = tone || 'neutral';
};
const setBusy = function (busy) {
inspectButton.disabled = busy;
outputButton.disabled =
busy || !currentTarget || currentTarget.outputAvailable !== true;
};
const request = async function (operation, target) {
const response = await fetch("/api/v1/copilot/" + operation, {
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: commandSchema,
operation: operation,
projectId: target.projectId,
sourceRunId: target.sourceRunId,
requestId: target.requestId,
}),
const setBusy = function (next) {
busy = next;
document.querySelectorAll('[data-read]').forEach(function (button) {
button.disabled = next;
});
const body = await response.json();
if (!response.ok) {
const error = new Error(
typeof body.code === "string" ? body.code : "console_request_failed",
);
error.code = typeof body.code === "string" ? body.code : "console_request_failed";
throw error;
if (next) {
statusChip.textContent = '读取中';
statusChip.dataset.tone = 'busy';
} else if (statusChip.dataset.tone === 'busy') {
statusChip.textContent = '只读就绪';
statusChip.dataset.tone = 'success';
}
return body;
};
const targetFromForm = function () {
const form = new FormData(targetForm);
const base = function (operation) {
const projectId = value('project-id');
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) {
throw new Error('project_id_invalid');
}
return {
projectId: String(form.get("projectId") || "").trim(),
sourceRunId: String(form.get("sourceRunId") || "").trim(),
requestId: String(form.get("requestId") || "").trim(),
schema: schema,
operation: operation,
projectId: projectId,
requestId: requestId(),
};
};
const renderInspection = function (response) {
const result = response.result;
const fact = result.result;
currentTarget = {
projectId: fact.projectId,
sourceRunId: fact.sourceRunId,
requestId: fact.requestId,
outputAvailable: fact.outputAvailable,
};
emptyState.hidden = true;
resultView.hidden = false;
outputPanel.hidden = true;
outputText.textContent = "";
setText("admitted-at", dateTime(fact.admittedAtMs));
setText("stage", fact.stage || (fact.status === "running" ? "processing" : null));
setText("outcome", fact.outcome || fact.status);
setText("diagnosis-run", fact.diagnosisRunId);
setText("reason", fact.reason);
setText(
"tokens",
fact.usage === null ? null : fact.usage.totalTokens,
);
setText(
"cost",
fact.usage === null || fact.usage.costMicros === null
? null
: "$" + (fact.usage.costMicros / 1000000).toFixed(6),
);
statusChip.textContent = fact.status === "running" ? "诊断进行中" : fact.outcome;
statusChip.dataset.tone =
fact.status === "running"
? "running"
: fact.outcome === "succeeded"
? "success"
: "failed";
outputButton.disabled = fact.outputAvailable !== true;
setMessage(
fact.outputAvailable
? "状态已验证。诊断内容仍未读取。"
: "状态已验证;当前没有可读取的诊断内容。",
);
const payload = function (operation) {
const result = base(operation);
if (operation === 'inspect' || operation === 'output') {
result.sourceRunId = value('source-run-id');
result.requestId = value('diagnosis-request-id');
} else if (operation === 'run_list') {
result.afterCreatedAtMs = null;
result.afterRunId = null;
result.limit = 32;
} else if (operation === 'run_read') {
result.runId = value('run-id');
} else if (operation === 'run_event_list') {
result.runId = value('run-id');
result.afterSequence = 0;
result.limit = 32;
} else if (operation === 'run_step_list') {
result.runId = value('run-id');
result.afterStepKey = null;
result.afterStepRunId = null;
result.limit = 32;
} else if (operation === 'task_list') {
result.afterTaskId = null;
result.limit = 32;
} else if (operation === 'task_read') {
result.taskId = value('task-id');
} else if (operation === 'workflow_list') {
result.packageName = value('package-name');
} else {
result.packageName = value('package-name');
result.workflowId = value('workflow-id');
if (operation === 'workflow_run_list') {
result.afterAdmittedAtMs = null;
result.afterRunId = null;
result.limit = 32;
} else {
result.runId = value('workflow-run-id');
if (operation === 'workflow_event_list') {
result.afterSequence = 0;
result.limit = 32;
} else if (operation === 'workflow_step_list') {
result.afterStepKey = null;
result.afterStepRunId = null;
result.limit = 32;
}
}
}
return result;
};
const renderOutput = function (response) {
const nextPage = function (operation, prior, fact) {
const next = Object.assign({}, prior, { requestId: requestId() });
if (operation === 'run_list' && fact.hasMore === true && fact.next) {
next.afterCreatedAtMs = fact.next.createdAtMs;
next.afterRunId = fact.next.runId;
} else if (
operation === 'task_list' &&
fact.hasMore === true &&
fact.next
) {
next.afterTaskId = fact.next.taskId;
} else if (operation === 'run_event_list' && fact.hasMore === true) {
next.afterSequence = fact.nextAfterSequence;
} else if (
operation === 'run_step_list' &&
fact.hasMore === true &&
fact.next
) {
next.afterStepKey = fact.next.stepKey;
next.afterStepRunId = fact.next.stepRunId;
} else if (
operation === 'workflow_run_list' &&
fact.truncated === true &&
fact.next
) {
next.afterAdmittedAtMs = fact.next.admittedAtMs;
next.afterRunId = fact.next.runId;
} else if (
operation === 'workflow_event_list' &&
fact.truncated === true &&
fact.nextAfterSequence !== null
) {
next.afterSequence = fact.nextAfterSequence;
} else if (
operation === 'workflow_step_list' &&
fact.truncated === true &&
fact.next
) {
next.afterStepKey = fact.next.stepKey;
next.afterStepRunId = fact.next.id;
} else {
return null;
}
return next;
};
const appendEvidence = function (operation, request, response) {
const fact = response.result.result;
outputPanel.hidden = false;
outputText.textContent = fact.result.text;
setText("finish-reason", fact.result.finishReason);
setText("output-bytes", fact.reference.outputBytes);
setText("content-digest", fact.reference.contentDigest);
setMessage("诊断内容已显式读取;请把它当作不可信建议进行复核。");
outputPanel.scrollIntoView({ behavior: "smooth", block: "nearest" });
const entry = document.createElement('li');
entry.className = 'ledger-entry';
const header = document.createElement('header');
const title = document.createElement('h3');
const time = document.createElement('time');
const output = document.createElement('pre');
title.textContent = labels[operation];
time.textContent = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'short',
timeStyle: 'medium',
}).format(new Date());
output.tabIndex = 0;
output.textContent = JSON.stringify(fact, null, 2);
header.append(title, time);
entry.append(header, output);
const next = nextPage(operation, request, fact);
if (next) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = '显式读取下一页';
button.addEventListener('click', function () {
void execute(operation, next);
});
entry.append(button);
}
ledger.prepend(entry);
emptyState.hidden = true;
ledger.hidden = false;
};
sessionForm.addEventListener("submit", function (event) {
const execute = async function (operation, prepared) {
if (busy) return;
setBusy(true);
setMessage('正在读取 ' + labels[operation] + '…');
try {
const body = prepared || payload(operation);
const response = await fetch(routes[operation], {
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(body),
});
const responseBody = await response.json();
if (!response.ok) {
throw new Error(
typeof responseBody.code === 'string'
? responseBody.code
: 'console_request_failed',
);
}
appendEvidence(operation, body, responseBody);
setMessage(
labels[operation] + ' 已加入本页证据账本。刷新页面会清空。',
'success',
);
} catch (error) {
statusChip.textContent = '读取失败';
statusChip.dataset.tone = 'failed';
setMessage(
'无法读取:' +
(error instanceof Error ? error.message : 'console_request_failed'),
'error',
);
} finally {
setBusy(false);
}
};
sessionForm.addEventListener('submit', function (event) {
event.preventDefault();
const candidate = sessionInput.value.trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) {
setMessage("浏览器访问密钥格式无效。", "error");
setMessage('浏览器访问密钥格式无效。', 'error');
return;
}
sessionToken = candidate;
sessionInput.value = "";
sessionInput.value = '';
sessionForm.hidden = true;
targetForm.hidden = false;
setMessage("本次页面已解锁。访问密钥只保留在内存中。");
document.getElementById("project-id").focus();
controls.hidden = false;
statusChip.textContent = '只读就绪';
statusChip.dataset.tone = 'success';
setMessage('本页已解锁;Cluster credential 仍只存在于服务端。', 'success');
document.getElementById('project-id').focus();
});
targetForm.addEventListener("submit", async function (event) {
event.preventDefault();
const target = targetFromForm();
setBusy(true);
setMessage("正在读取 durable status…");
try {
const response = await request("inspect", target);
renderInspection(response);
} catch (error) {
currentTarget = null;
outputPanel.hidden = true;
statusChip.textContent = "读取失败";
statusChip.dataset.tone = "failed";
setMessage("无法读取诊断状态:" + error.code, "error");
} finally {
setBusy(false);
}
document.querySelectorAll('.mode-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.mode-tab').forEach(function (candidate) {
const selected = candidate === tab;
candidate.classList.toggle('active', selected);
candidate.setAttribute('aria-pressed', String(selected));
});
document.querySelectorAll('.mode-panel').forEach(function (panel) {
panel.hidden = panel.id !== tab.dataset.panel;
});
});
});
outputButton.addEventListener("click", async function () {
if (!currentTarget || currentTarget.outputAvailable !== true) return;
setBusy(true);
setMessage("正在显式读取诊断内容…");
try {
const response = await request("output", currentTarget);
renderOutput(response);
} catch (error) {
setMessage("无法读取诊断内容:" + error.code, "error");
} finally {
setBusy(false);
}
document.querySelectorAll('[data-read]').forEach(function (button) {
button.addEventListener('click', function () {
void execute(button.dataset.read);
});
});
window.addEventListener("pagehide", function () {
sessionToken = "";
currentTarget = null;
outputText.textContent = "";
window.addEventListener('pagehide', function () {
sessionToken = '';
ledger.textContent = '';
});
})();
@@ -4,214 +4,136 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta
name="description"
content="QingLong 3.0 Cluster Copilot 只读诊断台"
/>
<title>QingLong 3.0 · Copilot 诊断台</title>
<meta name="description" content="QingLong 3.0 Cluster 只读现场记录台" />
<title>QingLong 3.0 · Cluster 现场记录台</title>
<link rel="stylesheet" href="/app.css" />
<script src="/app.js" defer></script>
</head>
<body>
<a class="skip-link" href="#workspace">跳到诊断工作区</a>
<a class="skip-link" href="#workspace">跳到观察工作区</a>
<div class="shell">
<header class="masthead">
<div class="brand-lockup" aria-label="QingLong 3.0 Cluster Console">
<div class="brand-lockup">
<span class="brand-mark" aria-hidden="true">QL</span>
<div>
<p class="eyebrow">QingLong 3.0 / Cluster field console</p>
<h1>故障诊断,不替你执行</h1>
<p class="eyebrow">QingLong 3.0 / Cluster field ledger</p>
<h1>沿着证据读,不替集群做决定</h1>
</div>
</div>
<div class="boundary" aria-label="当前权限边界">
<span class="boundary-dot" aria-hidden="true"></span>
<span>只读边界</span>
<strong>inspect · output</strong>
<span>本机只读 BFF</span>
<strong>Run · Task · Workflow · Copilot</strong>
</div>
</header>
<main id="workspace" class="workspace">
<section class="control-deck" aria-labelledby="target-title">
<aside class="control-deck" aria-labelledby="control-title">
<div class="section-heading">
<p class="eyebrow">Target coordinates</p>
<h2 id="target-title">定位一次诊断</h2>
<p>
输入已存在的 Project、源 Run 和诊断请求。页面不会创建、取消或重试任何任务。
</p>
<p class="eyebrow">Observation coordinates</p>
<h2 id="control-title">选择要读取的事实</h2>
<p>每次按钮点击只发起一次有界 GET。页面不创建、取消、重试、轮询或缓存任何任务。</p>
</div>
<form id="session-form" class="session-gate" autocomplete="off">
<label for="session-token">浏览器访问密钥</label>
<div class="inline-control">
<input
id="session-token"
name="sessionToken"
type="password"
inputmode="text"
spellcheck="false"
autocomplete="off"
required
pattern="[A-Za-z0-9_\-]{43}"
aria-describedby="session-note"
/>
<button type="submit">解锁本次页面</button>
<input id="session-token" name="sessionToken" type="password" autocomplete="off" spellcheck="false" required pattern="[A-Za-z0-9_\-]{43}" />
<button type="submit">解锁本页</button>
</div>
<p id="session-note" class="field-note">
从独立的 0600 session 文件读取并粘贴;仅保留在当前页面内存,不会发送到 Cluster API。
</p>
<p class="field-note">从独立的 0600 session 文件粘贴;密钥只存在于当前页面内存。</p>
</form>
<form id="target-form" class="target-form" autocomplete="off" hidden>
<label for="project-id">Project</label>
<input
id="project-id"
name="projectId"
type="text"
maxlength="128"
spellcheck="false"
autocomplete="off"
placeholder="project-main"
required
/>
<div id="console-controls" hidden>
<label class="field-label" for="project-id">Project</label>
<input id="project-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="project-main" required />
<label for="run-id">源 Run</label>
<input
id="run-id"
name="sourceRunId"
type="text"
maxlength="36"
spellcheck="false"
autocomplete="off"
placeholder="run_..."
required
/>
<nav class="mode-tabs" aria-label="观察面">
<button type="button" class="mode-tab active" data-panel="runtime-panel" aria-pressed="true">运行态</button>
<button type="button" class="mode-tab" data-panel="workflow-panel" aria-pressed="false">工作流</button>
<button type="button" class="mode-tab" data-panel="copilot-panel" aria-pressed="false">Copilot</button>
</nav>
<label for="request-id">诊断请求</label>
<input
id="request-id"
name="requestId"
type="text"
maxlength="128"
spellcheck="false"
autocomplete="off"
placeholder="diag-request-..."
required
/>
<section id="runtime-panel" class="mode-panel">
<div class="control-group">
<div class="control-title"><span>01</span><strong>目录</strong></div>
<div class="button-pair">
<button type="button" data-read="task_list">读取 Task 列表</button>
<button type="button" data-read="run_list">读取 Run 列表</button>
</div>
</div>
<div class="control-group">
<div class="control-title"><span>02</span><strong>Task 详情</strong></div>
<input id="task-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="task-id" />
<button type="button" data-read="task_read">读取当前修订</button>
</div>
<div class="control-group">
<div class="control-title"><span>03</span><strong>Run 证据链</strong></div>
<input id="run-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="run-id" />
<div class="button-grid">
<button type="button" data-read="run_read">读取 Run</button>
<button type="button" data-read="run_step_list">读取 Steps</button>
<button type="button" data-read="run_event_list">读取 Events</button>
</div>
</div>
</section>
<div class="actions">
<button id="inspect-button" class="primary" type="submit">
读取诊断状态
</button>
<button id="output-button" type="button" disabled>
显式读取诊断内容
</button>
</div>
</form>
<section id="workflow-panel" class="mode-panel" hidden>
<label class="field-label" for="package-name">Package</label>
<input id="package-name" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="ops-package" />
<button type="button" data-read="workflow_list">读取 Workflow 列表</button>
<label class="field-label" for="workflow-id">Workflow</label>
<input id="workflow-id" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="daily-maintenance" />
<button type="button" data-read="workflow_run_list">读取 Workflow Runs</button>
<label class="field-label" for="workflow-run-id">Workflow Run UUID</label>
<input id="workflow-run-id" type="text" maxlength="36" autocomplete="off" spellcheck="false" placeholder="00000000-0000-4000-8000-000000000000" />
<div class="button-grid">
<button type="button" data-read="workflow_run_read">读取 Run</button>
<button type="button" data-read="workflow_step_list">读取 Steps</button>
<button type="button" data-read="workflow_event_list">读取 Events</button>
</div>
</section>
<section id="copilot-panel" class="mode-panel" hidden>
<label class="field-label" for="source-run-id">源 Run</label>
<input id="source-run-id" type="text" maxlength="36" autocomplete="off" spellcheck="false" placeholder="run-source-1" />
<label class="field-label" for="diagnosis-request-id">诊断请求</label>
<input id="diagnosis-request-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="diagnosis-request-1" />
<div class="button-pair">
<button type="button" class="primary" data-read="inspect">读取诊断状态</button>
<button type="button" data-read="output">显式读取诊断内容</button>
</div>
<p class="field-note">模型文本是不可信内容,只会以纯文本进入证据账本。</p>
</section>
</div>
<aside class="trust-note">
<span>Authority note</span>
<p>
Cluster credential 只存在于本机 BFF 的私有文件中。浏览器不能读取它,也不能调用 diagnose 或 cancel。
</p>
<span>Authority boundary</span>
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。</p>
</aside>
</section>
</aside>
<section class="evidence-panel" aria-labelledby="evidence-title">
<div class="evidence-header">
<div>
<p class="eyebrow">Durable evidence</p>
<h2 id="evidence-title">诊断轨迹</h2>
<p class="eyebrow">Explicit read ledger</p>
<h2 id="evidence-title">本页证据账本</h2>
</div>
<span id="status-chip" class="status-chip" data-tone="idle">等待目标</span>
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
</div>
<div id="empty-state" class="empty-state">
<div class="empty-glyph" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<h3>先读取状态,再决定是否查看内容</h3>
<p>
状态响应只包含有界、低敏的 durable facts。模型文本必须由你再次明确选择。
</p>
<div class="evidence-rail" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
<h3>一次读取,一条可复核事实</h3>
<p>先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。</p>
</div>
<div id="result-view" hidden>
<ol class="trace-rail" aria-label="诊断状态时间线">
<li>
<span class="trace-node"></span>
<div>
<small>ADMITTED</small>
<strong id="admitted-at"></strong>
</div>
</li>
<li>
<span class="trace-node"></span>
<div>
<small>STAGE</small>
<strong id="stage"></strong>
</div>
</li>
<li>
<span class="trace-node"></span>
<div>
<small>OUTCOME</small>
<strong id="outcome"></strong>
</div>
</li>
</ol>
<dl class="fact-grid">
<div>
<dt>Diagnosis Run</dt>
<dd id="diagnosis-run"></dd>
</div>
<div>
<dt>Reason</dt>
<dd id="reason"></dd>
</div>
<div>
<dt>Tokens</dt>
<dd id="tokens"></dd>
</div>
<div>
<dt>Settled cost</dt>
<dd id="cost"></dd>
</div>
</dl>
<section id="output-panel" class="output-panel" hidden>
<div class="output-heading">
<div>
<p class="eyebrow">Explicit content read</p>
<h3>诊断内容</h3>
</div>
<span>不可信模型输出</span>
</div>
<pre id="output-text" tabindex="0"></pre>
<dl class="output-meta">
<div>
<dt>Finish reason</dt>
<dd id="finish-reason"></dd>
</div>
<div>
<dt>Output bytes</dt>
<dd id="output-bytes"></dd>
</div>
<div>
<dt>Content digest</dt>
<dd id="content-digest"></dd>
</div>
</dl>
</section>
</div>
<ol id="ledger" class="ledger" aria-live="polite" hidden></ol>
<div id="message" class="message" role="status" aria-live="polite"></div>
</section>
</main>
<footer>
<span>Loopback only · no legacy session · no browser credential</span>
<span>QingLong 3.0 incubation / D-327</span>
<span>Loopback only · explicit reads · zero polling</span>
<span>QingLong 3.0 incubation / D-329</span>
</footer>
</div>
</body>
@@ -75,6 +75,24 @@ export interface ClusterCopilotClientReadiness {
readonly ready: boolean;
}
/**
* Package-internal read transport shared by the Copilot client and the
* loopback operator console. The caller owns the reviewed path vocabulary;
* this boundary still rejects non-Project, mutation and cross-origin targets.
*/
export interface ClusterProjectApiReadExecution {
readonly configFile: string;
readonly credentialFile: string;
readonly path: string;
readonly requestId: string;
}
export interface ClusterProjectApiReadResult {
readonly schemaVersion: 1;
readonly requestId: string;
readonly result: Readonly<Record<string, unknown>>;
}
interface PreparedClusterCopilotClientConfiguration {
readonly endpoint: URL;
readonly servername: string;
@@ -134,6 +152,163 @@ const DNS_NAME =
const API_CREDENTIAL =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const RESPONSE_CODE = /^[a-z][a-z0-9_]{0,127}$/;
const TRANSPORT_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const PROJECT_SEGMENT = '[A-Za-z0-9][A-Za-z0-9._:-]{0,127}';
const PACKAGE_SEGMENT = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
const WORKFLOW_SEGMENT = '[a-z][a-z0-9-]{0,62}';
const UUID_SEGMENT =
'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
const POSITIVE_LIMIT = '(?:[1-9]|[1-5][0-9]|6[0-4])';
const NON_NEGATIVE_INTEGER = '(?:0|[1-9][0-9]{0,15})';
const projectRoot = '/api/v3/projects/' + PROJECT_SEGMENT;
const PROJECT_READ_PATHS = Object.freeze([
new RegExp('^' + projectRoot + '/runs\\?limit=' + POSITIVE_LIMIT + '$'),
new RegExp(
'^' +
projectRoot +
'/runs\\?after_created_at_ms=' +
NON_NEGATIVE_INTEGER +
'&after_run_id=' +
PROJECT_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp('^' + projectRoot + '/runs/' + PROJECT_SEGMENT + '$'),
new RegExp(
'^' +
projectRoot +
'/runs/' +
PROJECT_SEGMENT +
'/events\\?after_sequence=' +
NON_NEGATIVE_INTEGER +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/runs/' +
PROJECT_SEGMENT +
'/steps\\?limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/runs/' +
PROJECT_SEGMENT +
'/steps\\?after_step_key=' +
PROJECT_SEGMENT +
'&after_step_run_id=' +
PROJECT_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp('^' + projectRoot + '/tasks\\?limit=' + POSITIVE_LIMIT + '$'),
new RegExp(
'^' +
projectRoot +
'/tasks\\?after_task_id=' +
PROJECT_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp('^' + projectRoot + '/tasks/' + PROJECT_SEGMENT + '$'),
new RegExp(
'^' + projectRoot + '/packages/' + PACKAGE_SEGMENT + '/workflows$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs\\?limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs\\?after_admitted_at_ms=' +
NON_NEGATIVE_INTEGER +
'&after_run_id=' +
UUID_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'/events\\?after_sequence=' +
NON_NEGATIVE_INTEGER +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'/steps\\?limit=' +
POSITIVE_LIMIT +
'$',
),
new RegExp(
'^' +
projectRoot +
'/packages/' +
PACKAGE_SEGMENT +
'/workflows/' +
WORKFLOW_SEGMENT +
'/runs/' +
UUID_SEGMENT +
'/steps\\?after_step_key=' +
WORKFLOW_SEGMENT +
'&after_step_run_id=' +
UUID_SEGMENT +
'&limit=' +
POSITIVE_LIMIT +
'$',
),
]);
function projectReadPathValid(value: string): boolean {
return PROJECT_READ_PATHS.some((pattern) => pattern.test(value));
}
function configurationFailure(): never {
throw new ClusterCopilotClientConfigurationError();
@@ -413,10 +588,7 @@ function requestJson(
} catch (cause) {
throw new ClusterCopilotClientRequestError({ cause });
}
finish(
undefined,
Object.freeze({ ...provisional, body }),
);
finish(undefined, Object.freeze({ ...provisional, body }));
} catch (error) {
finish(
error instanceof ClusterCopilotClientRequestError
@@ -447,10 +619,7 @@ function requestJson(
});
}
function responseRequestId(
response: JsonResponse,
expected: string,
): string {
function responseRequestId(response: JsonResponse, expected: string): string {
const value = response.headers['x-request-id'];
if (
rawHeaderCount(response.rawHeaders, 'x-request-id') !== 1 ||
@@ -462,7 +631,9 @@ function responseRequestId(
return value;
}
function retryAfterSeconds(value: string | string[] | undefined): number | null {
function retryAfterSeconds(
value: string | string[] | undefined,
): number | null {
if (typeof value !== 'string' || !/^[1-9][0-9]{0,3}$/.test(value)) {
return null;
}
@@ -480,7 +651,9 @@ function remoteCode(value: unknown): string {
keys.length < 1 ||
keys.length > 3 ||
keys[0] !== 'code' ||
keys.some((key) => key !== 'code' && key !== 'reason' && key !== 'schema') ||
keys.some(
(key) => key !== 'code' && key !== 'reason' && key !== 'schema',
) ||
typeof record.code !== 'string' ||
!RESPONSE_CODE.test(record.code)
) {
@@ -521,9 +694,7 @@ function readCredentialBytes(credentialFile: string): Buffer {
return bytes;
} catch (error) {
bytes?.fill(0);
if (
error instanceof ClusterCopilotClientConfigurationError
) {
if (error instanceof ClusterCopilotClientConfigurationError) {
throw error;
}
throw new ClusterCopilotClientConfigurationError();
@@ -552,8 +723,7 @@ export async function probeClusterCopilotClientReadiness(
);
const status = readinessStatus(response.body);
const ready = response.statusCode === 200 && status === 'ready';
const notReady =
response.statusCode === 503 && status === 'not_ready';
const notReady = response.statusCode === 503 && status === 'not_ready';
if (!ready && !notReady) throw new ClusterCopilotClientRequestError();
return Object.freeze({ schemaVersion: 1, transport: 'https', ready });
} catch (error) {
@@ -594,10 +764,7 @@ async function executeNormalizedClusterCopilotCommand(
);
if (request.body !== null) {
bodyBytes = Buffer.from(JSON.stringify(request.body), 'utf8');
if (
bodyBytes.length < 2 ||
bodyBytes.length > MAXIMUM_COMMAND_BYTES
) {
if (bodyBytes.length < 2 || bodyBytes.length > MAXIMUM_COMMAND_BYTES) {
return configurationFailure();
}
}
@@ -661,11 +828,7 @@ export async function executeClusterCopilotCommand(
options?: ClusterCopilotClientOptions,
): Promise<Readonly<ClusterCopilotClientResult>> {
const normalizedOptions = validateOptions(options);
const record = exact(execution, [
'command',
'configFile',
'credentialFile',
]);
const record = exact(execution, ['command', 'configFile', 'credentialFile']);
const command = normalizeClusterCopilotClientCommand(record.command);
return executeNormalizedClusterCopilotCommand(
record.configFile as string,
@@ -680,11 +843,7 @@ export async function executeClusterCopilotClient(
options?: ClusterCopilotClientOptions,
): Promise<Readonly<ClusterCopilotClientResult>> {
const normalizedOptions = validateOptions(options);
const record = exact(paths, [
'commandFile',
'configFile',
'credentialFile',
]);
const record = exact(paths, ['commandFile', 'configFile', 'credentialFile']);
let commandBytes: Buffer | undefined;
try {
commandBytes = readCanonicalFile(
@@ -712,3 +871,89 @@ export async function executeClusterCopilotClient(
commandBytes?.fill(0);
}
}
export async function executeClusterProjectApiRead(
execution: ClusterProjectApiReadExecution,
options?: ClusterCopilotClientOptions,
): Promise<Readonly<ClusterProjectApiReadResult>> {
const normalizedOptions = validateOptions(options);
const record = exact(execution, [
'configFile',
'credentialFile',
'path',
'requestId',
]);
if (
typeof record.path !== 'string' ||
record.path.length > 2_048 ||
!projectReadPathValid(record.path) ||
record.path.includes('..') ||
record.path.includes('//') ||
typeof record.requestId !== 'string' ||
!TRANSPORT_REQUEST_ID.test(record.requestId)
) {
throw new ClusterCopilotClientRequestError();
}
let credentialBytes: Buffer | undefined;
let prepared: PreparedClusterCopilotClientConfiguration | undefined;
try {
prepared = prepareConfiguration(record.configFile as string);
credentialBytes = readCredentialBytes(record.credentialFile as string);
const response = await requestJson(
prepared,
Object.freeze({
method: 'GET',
path: record.path,
requestId: record.requestId,
authorization: `Bearer ${credentialBytes.toString('ascii')}`,
}),
MAXIMUM_RESPONSE_BYTES,
normalizedOptions,
);
const requestId = responseRequestId(response, record.requestId);
if (response.statusCode === 200) {
if (
!response.body ||
typeof response.body !== 'object' ||
Array.isArray(response.body)
) {
throw new ClusterCopilotClientRequestError();
}
return Object.freeze({
schemaVersion: 1,
requestId,
result: Object.freeze({
...(response.body as Record<string, unknown>),
}),
});
}
if (response.statusCode >= 400 && response.statusCode <= 599) {
throw new ClusterCopilotClientRemoteError(
response.statusCode,
remoteCode(response.body),
requestId,
retryAfterSeconds(response.headers['retry-after']),
);
}
throw new ClusterCopilotClientRequestError();
} catch (error) {
if (
error instanceof ClusterPluginPackageManagementClientConfigurationError
) {
throw new ClusterCopilotClientConfigurationError();
}
if (
error instanceof ClusterCopilotClientConfigurationError ||
error instanceof ClusterCopilotClientRequestError ||
error instanceof ClusterCopilotClientRemoteError
) {
throw error;
}
throw new ClusterCopilotClientRequestError({
cause: error instanceof Error ? error : undefined,
});
} finally {
credentialBytes?.fill(0);
prepared?.dispose();
}
}
@@ -1,10 +1,5 @@
import { createHash } from 'node:crypto';
import {
lstatSync,
readFileSync,
realpathSync,
type PathLike,
} from 'node:fs';
import { lstatSync, readFileSync, realpathSync, type PathLike } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
import { TextDecoder } from 'node:util';
@@ -28,19 +23,19 @@ const ASSETS = Object.freeze([
name: 'index.html',
field: 'html',
maximumBytes: 32 * 1024,
digest: 'f9fa959f30b92c6b000eecb744ce1d0a7fce822c62b3e17dcf10d4d579a072ac',
digest: 'ed8db5c26dec23e7a5237ef1cd4f5f9c3fc9f5a04a4751b7a3e0ed22dac54c42',
}),
Object.freeze({
name: 'app.css',
field: 'css',
maximumBytes: 64 * 1024,
digest: '200c3405e1e12329fcfb50509b31b19f1567a91552865f039ce0c2de1530032c',
digest: '54234cbba7e110de2f68fad2abd657c334b7e3e80c5d9b4f59bda7e122b4b62f',
}),
Object.freeze({
name: 'app.js',
field: 'javascript',
maximumBytes: 32 * 1024,
digest: 'd60913e725e767d9fa2cb65d60c0eae6d75d219f4bec8aad166bed8b6507fe02',
digest: '61811eac6a89b097b67823ccf49b0736af6494be7b187dbdcbecfc59adb3fce0',
}),
] as const);
@@ -2,13 +2,19 @@
import {
executeClusterCopilotCommand,
executeClusterProjectApiRead,
probeClusterCopilotClientReadiness,
validateClusterCopilotClientConfiguration,
validateClusterCopilotClientCredentialFile,
type ClusterCopilotClientCommand,
} from '../copilot-client/client';
import { readCanonicalFile } from '../management-support/managementClientConfiguration';
import { loadClusterCopilotConsoleAssets } from './assets';
import {
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
clusterCopilotConsoleClientCommand,
clusterCopilotConsoleProjectReadPath,
type ClusterCopilotConsoleReadRequest,
} from './contracts';
import {
clusterCopilotConsoleSessionDigest,
startClusterCopilotConsoleServer,
@@ -28,9 +34,7 @@ interface ClusterCopilotConsoleCliArguments {
readonly check: boolean;
readonly configFile: string;
readonly credentialFile: string;
readonly networkBoundary:
| 'host-loopback'
| 'container-published-loopback';
readonly networkBoundary: 'host-loopback' | 'container-published-loopback';
readonly sessionFile: string;
readonly port: number;
}
@@ -154,11 +158,7 @@ export function parseClusterCopilotConsoleCliArguments(
function readSessionDigest(sessionFile: string): Buffer {
let bytes: Buffer | undefined;
try {
bytes = readCanonicalFile(
sessionFile,
MAXIMUM_SESSION_BYTES,
'private',
);
bytes = readCanonicalFile(sessionFile, MAXIMUM_SESSION_BYTES, 'private');
if (
bytes.some((byte) => byte > 0x7f) ||
!SESSION_TOKEN.test(bytes.toString('ascii'))
@@ -199,7 +199,7 @@ async function main(): Promise<void> {
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
mutation: false,
}) + '\n',
);
@@ -213,11 +213,19 @@ async function main(): Promise<void> {
const server = await startClusterCopilotConsoleServer({
assets,
executor: Object.freeze({
execute(command: Readonly<ClusterCopilotClientCommand>) {
return executeClusterCopilotCommand({
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
if (request.operation === 'inspect' || request.operation === 'output') {
return executeClusterCopilotCommand({
configFile: parsed.configFile,
credentialFile: parsed.credentialFile,
command: clusterCopilotConsoleClientCommand(request),
});
}
return executeClusterProjectApiRead({
configFile: parsed.configFile,
credentialFile: parsed.credentialFile,
command,
path: clusterCopilotConsoleProjectReadPath(request),
requestId: request.requestId,
});
},
}),
@@ -236,7 +244,7 @@ async function main(): Promise<void> {
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
mutation: false,
}) + '\n',
);
@@ -8,16 +8,85 @@ export const CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA =
export const CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA =
'qinglong/cluster-copilot-console-read-response@v1' as const;
export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output';
export const CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS = Object.freeze([
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
] as const);
export interface ClusterCopilotConsoleReadRequest {
export type ClusterCopilotConsoleReadOperation =
(typeof CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS)[number];
interface BaseReadRequest<
Operation extends ClusterCopilotConsoleReadOperation,
> {
readonly schema: typeof CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA;
readonly operation: ClusterCopilotConsoleReadOperation;
readonly operation: Operation;
readonly projectId: string;
readonly sourceRunId: string;
readonly requestId: string;
}
export type ClusterCopilotConsoleReadRequest =
| (BaseReadRequest<'inspect'> & Readonly<{ sourceRunId: string }>)
| (BaseReadRequest<'output'> & Readonly<{ sourceRunId: string }>)
| (BaseReadRequest<'run_list'> &
Readonly<{
afterCreatedAtMs: number | null;
afterRunId: string | null;
limit: number;
}>)
| (BaseReadRequest<'run_read'> & Readonly<{ runId: string }>)
| (BaseReadRequest<'run_event_list'> &
Readonly<{ runId: string; afterSequence: number; limit: number }>)
| (BaseReadRequest<'run_step_list'> &
Readonly<{
runId: string;
afterStepKey: string | null;
afterStepRunId: string | null;
limit: number;
}>)
| (BaseReadRequest<'task_list'> &
Readonly<{ afterTaskId: string | null; limit: number }>)
| (BaseReadRequest<'task_read'> & Readonly<{ taskId: string }>)
| (BaseReadRequest<'workflow_list'> & Readonly<{ packageName: string }>)
| (BaseReadRequest<'workflow_run_list'> &
Readonly<{
packageName: string;
workflowId: string;
afterAdmittedAtMs: number | null;
afterRunId: string | null;
limit: number;
}>)
| (BaseReadRequest<'workflow_run_read'> &
Readonly<{ packageName: string; workflowId: string; runId: string }>)
| (BaseReadRequest<'workflow_event_list'> &
Readonly<{
packageName: string;
workflowId: string;
runId: string;
afterSequence: number;
limit: number;
}>)
| (BaseReadRequest<'workflow_step_list'> &
Readonly<{
packageName: string;
workflowId: string;
runId: string;
afterStepKey: string | null;
afterStepRunId: string | null;
limit: number;
}>);
export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID';
@@ -28,47 +97,305 @@ export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
}
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
const COPILOT_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const WORKFLOW_ID = /^[a-z][a-z0-9-]{0,62}$/;
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function invalid(): never {
throw new InvalidClusterCopilotConsoleReadRequestError();
}
export function normalizeClusterCopilotConsoleReadRequest(
value: unknown,
): Readonly<ClusterCopilotConsoleReadRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalid();
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
function exact(
record: Record<string, unknown>,
operation: ClusterCopilotConsoleReadOperation,
fields: readonly string[],
): void {
const actual = Object.keys(record).sort();
const expected = [
'operation',
'projectId',
'requestId',
'schema',
'sourceRunId',
];
...fields,
].sort();
if (
keys.length !== expected.length ||
keys.some((key, index) => key !== expected[index]) ||
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index]) ||
record.schema !== CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA ||
(record.operation !== 'inspect' && record.operation !== 'output') ||
record.operation !== operation ||
typeof record.projectId !== 'string' ||
!IDENTITY.test(record.projectId) ||
typeof record.sourceRunId !== 'string' ||
!RUN_ID.test(record.sourceRunId) ||
typeof record.requestId !== 'string' ||
!IDENTITY.test(record.requestId)
) {
return invalid();
}
return Object.freeze({
)
invalid();
}
function identifier(value: unknown): value is string {
return typeof value === 'string' && IDENTITY.test(value);
}
function limit(value: unknown): value is number {
return (
Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= 64
);
}
function sequence(value: unknown): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= 0 &&
Number(value) <= 2_147_483_647
);
}
function timestamp(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function common(record: Record<string, unknown>) {
return {
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: record.operation,
projectId: record.projectId,
sourceRunId: record.sourceRunId,
requestId: record.requestId,
projectId: record.projectId as string,
requestId: record.requestId as string,
} as const;
}
function workflowTarget(
record: Record<string, unknown>,
requireRun: boolean,
): boolean {
return (
typeof record.packageName === 'string' &&
PACKAGE_NAME.test(record.packageName) &&
typeof record.workflowId === 'string' &&
WORKFLOW_ID.test(record.workflowId) &&
(!requireRun ||
(typeof record.runId === 'string' && UUID_V4.test(record.runId)))
);
}
export function normalizeClusterCopilotConsoleReadRequest(
value: unknown,
): Readonly<ClusterCopilotConsoleReadRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
const record = value as Record<string, unknown>;
const operation = record.operation;
if (
typeof operation !== 'string' ||
!CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.includes(
operation as ClusterCopilotConsoleReadOperation,
)
)
invalid();
const op = operation as ClusterCopilotConsoleReadOperation;
if (op === 'inspect' || op === 'output') {
exact(record, op, ['sourceRunId']);
if (
typeof record.sourceRunId !== 'string' ||
!COPILOT_RUN_ID.test(record.sourceRunId)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
sourceRunId: record.sourceRunId,
});
}
if (op === 'run_list') {
exact(record, op, ['afterCreatedAtMs', 'afterRunId', 'limit']);
if (
(record.afterCreatedAtMs === null) !== (record.afterRunId === null) ||
(record.afterCreatedAtMs !== null &&
!timestamp(record.afterCreatedAtMs)) ||
(record.afterRunId !== null && !identifier(record.afterRunId)) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
afterCreatedAtMs: record.afterCreatedAtMs as number | null,
afterRunId: record.afterRunId as string | null,
limit: record.limit as number,
});
}
if (op === 'run_read') {
exact(record, op, ['runId']);
if (!identifier(record.runId)) invalid();
return Object.freeze({
...common(record),
operation: op,
runId: record.runId,
});
}
if (op === 'run_event_list') {
exact(record, op, ['afterSequence', 'limit', 'runId']);
if (
!identifier(record.runId) ||
!sequence(record.afterSequence) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
runId: record.runId,
afterSequence: record.afterSequence as number,
limit: record.limit as number,
});
}
if (op === 'run_step_list') {
exact(record, op, ['afterStepKey', 'afterStepRunId', 'limit', 'runId']);
if (
!identifier(record.runId) ||
(record.afterStepKey === null) !== (record.afterStepRunId === null) ||
(record.afterStepKey !== null && !identifier(record.afterStepKey)) ||
(record.afterStepRunId !== null && !identifier(record.afterStepRunId)) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
runId: record.runId,
afterStepKey: record.afterStepKey as string | null,
afterStepRunId: record.afterStepRunId as string | null,
limit: record.limit as number,
});
}
if (op === 'task_list') {
exact(record, op, ['afterTaskId', 'limit']);
if (
(record.afterTaskId !== null && !identifier(record.afterTaskId)) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
afterTaskId: record.afterTaskId as string | null,
limit: record.limit as number,
});
}
if (op === 'task_read') {
exact(record, op, ['taskId']);
if (!identifier(record.taskId)) invalid();
return Object.freeze({
...common(record),
operation: op,
taskId: record.taskId,
});
}
if (op === 'workflow_list') {
exact(record, op, ['packageName']);
if (
typeof record.packageName !== 'string' ||
!PACKAGE_NAME.test(record.packageName)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName,
});
}
if (op === 'workflow_run_list') {
exact(record, op, [
'afterAdmittedAtMs',
'afterRunId',
'limit',
'packageName',
'workflowId',
]);
if (
!workflowTarget(record, false) ||
(record.afterAdmittedAtMs === null) !== (record.afterRunId === null) ||
(record.afterAdmittedAtMs !== null &&
!timestamp(record.afterAdmittedAtMs)) ||
(record.afterRunId !== null &&
(typeof record.afterRunId !== 'string' ||
!UUID_V4.test(record.afterRunId))) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName as string,
workflowId: record.workflowId as string,
afterAdmittedAtMs: record.afterAdmittedAtMs as number | null,
afterRunId: record.afterRunId as string | null,
limit: record.limit as number,
});
}
if (op === 'workflow_run_read') {
exact(record, op, ['packageName', 'runId', 'workflowId']);
if (!workflowTarget(record, true)) invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName as string,
workflowId: record.workflowId as string,
runId: record.runId as string,
});
}
if (op === 'workflow_event_list') {
exact(record, op, [
'afterSequence',
'limit',
'packageName',
'runId',
'workflowId',
]);
if (
!workflowTarget(record, true) ||
!sequence(record.afterSequence) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: op,
packageName: record.packageName as string,
workflowId: record.workflowId as string,
runId: record.runId as string,
afterSequence: record.afterSequence as number,
limit: record.limit as number,
});
}
exact(record, 'workflow_step_list', [
'afterStepKey',
'afterStepRunId',
'limit',
'packageName',
'runId',
'workflowId',
]);
if (
!workflowTarget(record, true) ||
(record.afterStepKey === null) !== (record.afterStepRunId === null) ||
(record.afterStepKey !== null &&
(typeof record.afterStepKey !== 'string' ||
!WORKFLOW_ID.test(record.afterStepKey))) ||
(record.afterStepRunId !== null &&
(typeof record.afterStepRunId !== 'string' ||
!UUID_V4.test(record.afterStepRunId))) ||
!limit(record.limit)
)
invalid();
return Object.freeze({
...common(record),
operation: 'workflow_step_list',
packageName: record.packageName as string,
workflowId: record.workflowId as string,
runId: record.runId as string,
afterStepKey: record.afterStepKey as string | null,
afterStepRunId: record.afterStepRunId as string | null,
limit: record.limit as number,
});
}
@@ -76,6 +403,8 @@ export function clusterCopilotConsoleClientCommand(
request: Readonly<ClusterCopilotConsoleReadRequest>,
): Readonly<ClusterCopilotClientCommand> {
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
if (normalized.operation !== 'inspect' && normalized.operation !== 'output')
invalid();
return Object.freeze({
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
operation: normalized.operation,
@@ -84,3 +413,111 @@ export function clusterCopilotConsoleClientCommand(
requestId: normalized.requestId,
});
}
function encoded(value: string): string {
return encodeURIComponent(value);
}
function query(
entries: readonly (readonly [string, string | number])[],
): string {
return entries.length === 0
? ''
: '?' +
entries
.map(([key, value]) => encoded(key) + '=' + encoded(String(value)))
.join('&');
}
export function clusterCopilotConsoleProjectReadPath(
request: Readonly<ClusterCopilotConsoleReadRequest>,
): string {
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
if (normalized.operation === 'inspect' || normalized.operation === 'output')
invalid();
const project = '/api/v3/projects/' + encoded(normalized.projectId);
if (normalized.operation === 'run_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterCreatedAtMs === null
? []
: [
['after_created_at_ms', normalized.afterCreatedAtMs],
['after_run_id', normalized.afterRunId!],
];
return project + '/runs' + query([...cursor, ['limit', normalized.limit]]);
}
if (normalized.operation === 'run_read')
return project + '/runs/' + encoded(normalized.runId);
if (normalized.operation === 'run_event_list')
return (
project +
'/runs/' +
encoded(normalized.runId) +
'/events' +
query([
['after_sequence', normalized.afterSequence],
['limit', normalized.limit],
])
);
if (normalized.operation === 'run_step_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterStepKey === null
? []
: [
['after_step_key', normalized.afterStepKey],
['after_step_run_id', normalized.afterStepRunId!],
];
return (
project +
'/runs/' +
encoded(normalized.runId) +
'/steps' +
query([...cursor, ['limit', normalized.limit]])
);
}
if (normalized.operation === 'task_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterTaskId === null
? []
: [['after_task_id', normalized.afterTaskId]];
return project + '/tasks' + query([...cursor, ['limit', normalized.limit]]);
}
if (normalized.operation === 'task_read')
return project + '/tasks/' + encoded(normalized.taskId);
const packageRoot = project + '/packages/' + encoded(normalized.packageName);
if (normalized.operation === 'workflow_list')
return packageRoot + '/workflows';
const workflowRoot =
packageRoot + '/workflows/' + encoded(normalized.workflowId);
if (normalized.operation === 'workflow_run_list') {
const cursor: (readonly [string, string | number])[] =
normalized.afterAdmittedAtMs === null
? []
: [
['after_admitted_at_ms', normalized.afterAdmittedAtMs],
['after_run_id', normalized.afterRunId!],
];
return (
workflowRoot + '/runs' + query([...cursor, ['limit', normalized.limit]])
);
}
const runRoot = workflowRoot + '/runs/' + encoded(normalized.runId);
if (normalized.operation === 'workflow_run_read') return runRoot;
if (normalized.operation === 'workflow_event_list')
return (
runRoot +
'/events' +
query([
['after_sequence', normalized.afterSequence],
['limit', normalized.limit],
])
);
const cursor: (readonly [string, string | number])[] =
normalized.afterStepKey === null
? []
: [
['after_step_key', normalized.afterStepKey],
['after_step_run_id', normalized.afterStepRunId!],
];
return runRoot + '/steps' + query([...cursor, ['limit', normalized.limit]]);
}
@@ -9,17 +9,14 @@ import {
ClusterCopilotClientConfigurationError,
ClusterCopilotClientRemoteError,
ClusterCopilotClientRequestError,
type ClusterCopilotClientCommand,
type ClusterCopilotClientResult,
} from '../copilot-client/client';
import {
type ClusterCopilotConsoleAssets,
} from './assets';
import { type ClusterCopilotConsoleAssets } from './assets';
import {
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
InvalidClusterCopilotConsoleReadRequestError,
clusterCopilotConsoleClientCommand,
normalizeClusterCopilotConsoleReadRequest,
type ClusterCopilotConsoleReadOperation,
type ClusterCopilotConsoleReadRequest,
} from './contracts';
export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
@@ -31,9 +28,13 @@ export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
});
export interface ClusterCopilotConsoleExecutor {
execute(
command: Readonly<ClusterCopilotClientCommand>,
): Promise<Readonly<ClusterCopilotClientResult>>;
execute(request: Readonly<ClusterCopilotConsoleReadRequest>): Promise<
Readonly<{
schemaVersion: 1;
requestId: string;
result: Readonly<Record<string, unknown>>;
}>
>;
}
export interface ClusterCopilotConsoleServerOptions {
@@ -111,10 +112,7 @@ export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
return invalid();
}
const decoded = Buffer.from(value, 'base64url');
if (
decoded.byteLength !== 32 ||
decoded.toString('base64url') !== value
) {
if (decoded.byteLength !== 32 || decoded.toString('base64url') !== value) {
decoded.fill(0);
return invalid();
}
@@ -125,7 +123,9 @@ export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
.digest();
}
function securityHeaders(contentType: string): Readonly<Record<string, string>> {
function securityHeaders(
contentType: string,
): Readonly<Record<string, string>> {
return Object.freeze({
'cache-control': 'no-store',
'content-security-policy': CONTENT_SECURITY_POLICY,
@@ -181,11 +181,29 @@ function headerCount(request: IncomingMessage, name: string): number {
return count;
}
function targetPath(request: IncomingMessage): 'inspect' | 'output' | null {
const READ_ROUTES: Readonly<
Record<string, ClusterCopilotConsoleReadOperation>
> = Object.freeze({
'/api/v1/copilot/inspect': 'inspect',
'/api/v1/copilot/output': 'output',
'/api/v1/observe/run-list': 'run_list',
'/api/v1/observe/run': 'run_read',
'/api/v1/observe/run-events': 'run_event_list',
'/api/v1/observe/run-steps': 'run_step_list',
'/api/v1/observe/task-list': 'task_list',
'/api/v1/observe/task': 'task_read',
'/api/v1/observe/workflow-list': 'workflow_list',
'/api/v1/observe/workflow-run-list': 'workflow_run_list',
'/api/v1/observe/workflow-run': 'workflow_run_read',
'/api/v1/observe/workflow-events': 'workflow_event_list',
'/api/v1/observe/workflow-steps': 'workflow_step_list',
});
function targetPath(
request: IncomingMessage,
): ClusterCopilotConsoleReadOperation | null {
if (request.method !== 'POST') return null;
if (request.url === '/api/v1/copilot/inspect') return 'inspect';
if (request.url === '/api/v1/copilot/output') return 'output';
return null;
return request.url === undefined ? null : READ_ROUTES[request.url] ?? null;
}
function authorize(
@@ -279,11 +297,7 @@ function remoteFailure(
error: ClusterCopilotClientRemoteError,
): void {
const statusCode =
error.statusCode === 404
? 404
: error.statusCode === 429
? 429
: 502;
error.statusCode === 404 ? 404 : error.statusCode === 429 ? 429 : 502;
sendJson(
response,
statusCode,
@@ -377,9 +391,7 @@ export async function startClusterCopilotConsoleServer(
request.resume();
return;
}
if (
inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests
) {
if (inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests) {
sendJson(
response,
429,
@@ -400,9 +412,7 @@ export async function startClusterCopilotConsoleServer(
if (normalized.operation !== operation) {
throw new InvalidClusterCopilotConsoleReadRequestError();
}
const result = await executor.execute(
clusterCopilotConsoleClientCommand(normalized),
);
const result = await executor.execute(normalized);
const envelope = Object.freeze({
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
operation,
@@ -416,12 +426,7 @@ export async function startClusterCopilotConsoleServer(
) {
throw new ClusterCopilotClientRequestError();
}
send(
response,
200,
'application/json; charset=utf-8',
encoded,
);
send(response, 200, 'application/json; charset=utf-8', encoded);
} catch (error) {
if (response.headersSent) {
response.destroy();
@@ -20,6 +20,7 @@ const {
CLUSTER_COPILOT_CLIENT_CONFIG_SCHEMA,
ClusterCopilotClientRemoteError,
executeClusterCopilotClient,
executeClusterProjectApiRead,
probeClusterCopilotClientReadiness,
validateClusterCopilotClientConfiguration,
} = require('../dist/copilot-client/client.js');
@@ -35,7 +36,9 @@ const {
validateClusterCopilotClientResponse,
} = require('../dist/copilot-client/contracts.js');
const credential = `ql3c_credential-1_${Buffer.alloc(32, 7).toString('base64url')}`;
const credential = `ql3c_credential-1_${Buffer.alloc(32, 7).toString(
'base64url',
)}`;
const baseCommand = {
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
projectId: 'project-1',
@@ -58,11 +61,7 @@ function temporaryDirectory(t) {
}
function configuration(directory, port) {
const caFile = privateFile(
directory,
'ca.pem',
fs.readFileSync(caFixture),
);
const caFile = privateFile(directory, 'ca.pem', fs.readFileSync(caFixture));
return privateFile(
directory,
'client.json',
@@ -113,9 +112,7 @@ async function startServer(handler) {
port: server.address().port,
close: () =>
new Promise((resolvePromise, reject) => {
server.close((error) =>
error ? reject(error) : resolvePromise(),
);
server.close((error) => (error ? reject(error) : resolvePromise()));
}),
};
}
@@ -266,7 +263,11 @@ test('normalizes only the four bounded commands and derives exact requests', ()
});
test('validates exact target-bound response state for every operation', () => {
const diagnose = { ...baseCommand, operation: 'diagnose', traceId: 'trace-1' };
const diagnose = {
...baseCommand,
operation: 'diagnose',
traceId: 'trace-1',
};
const inspect = { ...baseCommand, operation: 'inspect' };
const output = { ...baseCommand, operation: 'output' };
const cancel = {
@@ -307,8 +308,7 @@ test('validates exact target-bound response state for every operation', () => {
'diagnosis',
);
assert.equal(
validateClusterCopilotClientResponse(cancellationResponse(), cancel)
.status,
validateClusterCopilotClientResponse(cancellationResponse(), cancel).status,
'accepted',
);
assert.equal(
@@ -357,10 +357,10 @@ test('validates exact target-bound response state for every operation', () => {
validateClusterCopilotClientResponse(invalidOutput, output),
);
assert.throws(() =>
validateClusterCopilotClientResponse(
cancellationResponse(),
{ ...cancel, requestId: 'other-request' },
),
validateClusterCopilotClientResponse(cancellationResponse(), {
...cancel,
requestId: 'other-request',
}),
);
});
@@ -383,7 +383,10 @@ test('uses TLS 1.3, Bearer credential and exact request identities end to end',
if (request.url === '/readyz') {
assert.equal(request.headers.authorization, undefined);
jsonResponse(response, 200, null, { status: 'ready' });
} else if (request.method === 'POST' && request.url.endsWith('/cancellation')) {
} else if (
request.method === 'POST' &&
request.url.endsWith('/cancellation')
) {
jsonResponse(response, 202, requestId, cancellationResponse());
} else if (request.method === 'POST') {
jsonResponse(response, 201, requestId, diagnoseResponse());
@@ -448,6 +451,65 @@ test('uses TLS 1.3, Bearer credential and exact request identities end to end',
});
});
test('reuses the credential-safe TLS boundary for fixed Project API reads only', async (t) => {
const seen = [];
const server = await startServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
seen.push({
method: request.method,
url: request.url,
authorization: request.headers.authorization,
requestId: request.headers['x-request-id'],
bodyBytes: Buffer.concat(chunks).byteLength,
tls: request.socket.getProtocol(),
});
jsonResponse(response, 200, request.headers['x-request-id'], {
runs: [],
hasMore: false,
});
});
});
t.after(() => server.close());
const directory = temporaryDirectory(t);
const execution = {
configFile: configuration(directory, server.port),
credentialFile: privateFile(directory, 'credential', credential),
path: '/api/v3/projects/project-1/runs?limit=32',
requestId: 'console-read-1',
};
assert.deepEqual(await executeClusterProjectApiRead(execution), {
schemaVersion: 1,
requestId: 'console-read-1',
result: { runs: [], hasMore: false },
});
assert.deepEqual(seen, [
{
method: 'GET',
url: '/api/v3/projects/project-1/runs?limit=32',
authorization: `Bearer ${credential}`,
requestId: 'console-read-1',
bodyBytes: 0,
tls: 'TLSv1.3',
},
]);
await assert.rejects(
executeClusterProjectApiRead({
...execution,
path: '/api/v3/projects/project-1/runs/run-1/cancellation',
}),
{ code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED' },
);
await assert.rejects(
executeClusterProjectApiRead({
...execution,
path: 'https://attacker.example/api/v3/projects/project-1/runs',
}),
{ code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED' },
);
});
test('fails closed on weak files, request-id drift and low-sensitive remote errors', async (t) => {
let mode = 'readiness-drift';
const server = await startServer((request, response) => {
@@ -488,18 +550,15 @@ test('fails closed on weak files, request-id drift and low-sensitive remote erro
});
mode = 'remote';
await assert.rejects(
executeClusterCopilotClient(paths),
(error) => {
assert.equal(error instanceof ClusterCopilotClientRemoteError, true);
assert.equal(error.statusCode, 429);
assert.equal(error.responseCode, 'copilot_rate_limited');
assert.equal(error.requestId, baseCommand.requestId);
assert.equal(error.retryAfterSeconds, 30);
assert.equal(JSON.stringify(error).includes('private detail'), false);
return true;
},
);
await assert.rejects(executeClusterCopilotClient(paths), (error) => {
assert.equal(error instanceof ClusterCopilotClientRemoteError, true);
assert.equal(error.statusCode, 429);
assert.equal(error.responseCode, 'copilot_rate_limited');
assert.equal(error.requestId, baseCommand.requestId);
assert.equal(error.retryAfterSeconds, 30);
assert.equal(JSON.stringify(error).includes('private detail'), false);
return true;
});
const cli = await runCli([
`--config=${configFile}`,
@@ -602,11 +661,7 @@ test('rejects response framing drift, oversized bodies, aborts and timeouts', as
});
t.after(() => server.close());
const directory = temporaryDirectory(t);
const caFile = privateFile(
directory,
'ca.pem',
fs.readFileSync(caFixture),
);
const caFile = privateFile(directory, 'ca.pem', fs.readFileSync(caFixture));
const configFile = privateFile(
directory,
'client.json',
@@ -625,12 +680,7 @@ test('rejects response framing drift, oversized bodies, aborts and timeouts', as
}),
credentialFile: privateFile(directory, 'credential', credential),
};
for (const failureMode of [
'content-type',
'oversized',
'abort',
'timeout',
]) {
for (const failureMode of ['content-type', 'oversized', 'abort', 'timeout']) {
mode = failureMode;
await assert.rejects(executeClusterCopilotClient(paths), {
code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED',
@@ -16,6 +16,7 @@ const {
const {
CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
clusterCopilotConsoleClientCommand,
clusterCopilotConsoleProjectReadPath,
normalizeClusterCopilotConsoleReadRequest,
} = require('../dist/copilot-console/contracts.js');
const {
@@ -42,7 +43,8 @@ function inspection() {
operation: 'inspect',
requestId: 'transport-read-1',
result: {
schema: 'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1',
schema:
'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1',
status: 'terminal',
projectId: 'project-main',
sourceRunId: 'run-source-1',
@@ -72,7 +74,8 @@ function output() {
operation: 'output',
requestId: 'transport-read-2',
result: {
schema: 'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
schema:
'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
status: 'available',
projectId: 'project-main',
sourceRunId: 'run-source-1',
@@ -180,7 +183,7 @@ async function unusedPort() {
return port;
}
test('normalizes only the two read operations into the shared client contract', () => {
test('normalizes Copilot and fixed Project observation operations without arbitrary paths', () => {
assert.deepEqual(
clusterCopilotConsoleClientCommand(
normalizeClusterCopilotConsoleReadRequest(target('inspect')),
@@ -197,6 +200,22 @@ test('normalizes only the two read operations into the shared client contract',
clusterCopilotConsoleClientCommand(target('output')).operation,
'output',
);
const runList = normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'run_list',
projectId: 'project-main',
requestId: 'console-read-1',
afterCreatedAtMs: 1_700_000_000_000,
afterRunId: 'run-9',
limit: 32,
});
assert.equal(
clusterCopilotConsoleProjectReadPath(runList),
'/api/v3/projects/project-main/runs?after_created_at_ms=1700000000000&after_run_id=run-9&limit=32',
);
assert.throws(() => clusterCopilotConsoleClientCommand(runList), {
code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID',
});
assert.throws(
() =>
normalizeClusterCopilotConsoleReadRequest({
@@ -215,22 +234,162 @@ test('normalizes only the two read operations into the shared client contract',
);
});
test('maps every reviewed Project observation operation to one fixed GET path', () => {
const workflowRunId = '123e4567-e89b-42d3-a456-426614174000';
const workflowStepRunId = '123e4567-e89b-42d3-a456-426614174001';
const cases = [
[
{
operation: 'run_list',
afterCreatedAtMs: 1_700_000_000_000,
afterRunId: 'run-9',
limit: 32,
},
'/api/v3/projects/project-main/runs?after_created_at_ms=1700000000000&after_run_id=run-9&limit=32',
],
[
{ operation: 'run_read', runId: 'run-9' },
'/api/v3/projects/project-main/runs/run-9',
],
[
{
operation: 'run_event_list',
runId: 'run-9',
afterSequence: 7,
limit: 16,
},
'/api/v3/projects/project-main/runs/run-9/events?after_sequence=7&limit=16',
],
[
{
operation: 'run_step_list',
runId: 'run-9',
afterStepKey: 'model',
afterStepRunId: 'step-run-3',
limit: 8,
},
'/api/v3/projects/project-main/runs/run-9/steps?after_step_key=model&after_step_run_id=step-run-3&limit=8',
],
[
{ operation: 'task_list', afterTaskId: 'task-9', limit: 4 },
'/api/v3/projects/project-main/tasks?after_task_id=task-9&limit=4',
],
[
{ operation: 'task_read', taskId: 'task-9' },
'/api/v3/projects/project-main/tasks/task-9',
],
[
{ operation: 'workflow_list', packageName: 'ops-pack' },
'/api/v3/projects/project-main/packages/ops-pack/workflows',
],
[
{
operation: 'workflow_run_list',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
afterAdmittedAtMs: 1_700_000_000_000,
afterRunId: workflowRunId,
limit: 32,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs?after_admitted_at_ms=1700000000000&after_run_id=123e4567-e89b-42d3-a456-426614174000&limit=32',
],
[
{
operation: 'workflow_run_read',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: workflowRunId,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000',
],
[
{
operation: 'workflow_event_list',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: workflowRunId,
afterSequence: 9,
limit: 16,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000/events?after_sequence=9&limit=16',
],
[
{
operation: 'workflow_step_list',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: workflowRunId,
afterStepKey: 'publish',
afterStepRunId: workflowStepRunId,
limit: 8,
},
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000/steps?after_step_key=publish&after_step_run_id=123e4567-e89b-42d3-a456-426614174001&limit=8',
],
];
for (const [requestFields, expectedPath] of cases) {
const normalized = normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
projectId: 'project-main',
requestId: 'console-read-1',
...requestFields,
});
assert.equal(
clusterCopilotConsoleProjectReadPath(normalized),
expectedPath,
);
assert.equal(Object.isFrozen(normalized), true);
}
assert.throws(
() =>
normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'run_step_list',
projectId: 'project-main',
requestId: 'console-read-1',
runId: 'run-9',
afterStepKey: 'model',
afterStepRunId: null,
limit: 8,
}),
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
);
assert.throws(
() =>
normalizeClusterCopilotConsoleReadRequest({
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'workflow_run_read',
projectId: 'project-main',
requestId: 'console-read-1',
packageName: 'ops-pack',
workflowId: 'nightly-repair',
runId: 'not-a-workflow-run-uuid',
}),
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
);
});
test('loads only digest-bound packaged assets and rejects drift', async (t) => {
const assets = loadClusterCopilotConsoleAssets(moduleDirectory);
assert.match(assets.html, /故障诊断,不替你执行/);
assert.match(assets.html, /沿着证据读,不替集群做决定/);
assert.match(assets.css, /prefers-reduced-motion/);
assert.match(assets.javascript, /textContent = fact\.result\.text/);
assert.doesNotMatch(assets.javascript, /localStorage|sessionStorage|innerHTML/);
assert.match(assets.javascript, /output\.textContent = JSON\.stringify/);
assert.match(assets.javascript, /run_event_list/);
assert.doesNotMatch(
assets.javascript,
/localStorage|sessionStorage|innerHTML/,
);
const root = await mkdtemp(join(tmpdir(), 'ql3-console-assets-'));
t.after(() => require('node:fs').rmSync(root, { recursive: true, force: true }));
t.after(() =>
require('node:fs').rmSync(root, { recursive: true, force: true }),
);
const fakeModuleDirectory = join(root, 'dist', 'copilot-console');
await mkdir(fakeModuleDirectory, { recursive: true });
await cp(
resolve(moduleDirectory, '../../assets'),
join(root, 'assets'),
{ recursive: true },
);
await cp(resolve(moduleDirectory, '../../assets'), join(root, 'assets'), {
recursive: true,
});
await writeFile(
join(root, 'assets', 'copilot-console', 'app.js'),
'"drift";\n',
@@ -250,13 +409,16 @@ test('serves an immutable same-origin shell with a closed browser policy', async
assert.equal(html.headers['x-frame-options'], 'DENY');
assert.match(html.headers['content-security-policy'], /default-src 'none'/);
assert.match(html.headers['content-security-policy'], /connect-src 'self'/);
assert.match(html.text, /Cluster field console/);
assert.match(html.text, /Cluster field ledger/);
const css = await request(server.origin, { path: '/app.css' });
const javascript = await request(server.origin, { path: '/app.js' });
assert.equal(css.statusCode, 200);
assert.equal(javascript.statusCode, 200);
assert.equal(javascript.headers['content-type'], 'text/javascript; charset=utf-8');
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) => {
@@ -299,7 +461,7 @@ test('keeps the Cluster credential server-side and forwards one exact inspect',
assert.equal(response.statusCode, 200);
assert.deepEqual(commands, [
{
schema: 'qinglong/cluster-copilot-client-command@v1',
schema: 'qinglong/cluster-copilot-console-read-request@v1',
operation: 'inspect',
projectId: 'project-main',
sourceRunId: 'run-source-1',
@@ -314,6 +476,39 @@ test('keeps the Cluster credential server-side and forwards one exact inspect',
assert.doesNotMatch(response.text, /ql3c_|authorization|credential/i);
});
test('forwards one exact bounded Run list read and exposes no path field', async (t) => {
const requests = [];
const { server, headers } = await fixture(async (read) => {
requests.push(read);
return {
schemaVersion: 1,
requestId: read.requestId,
result: { runs: [], hasMore: false },
};
});
t.after(() => server.close());
const body = {
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
operation: 'run_list',
projectId: 'project-main',
requestId: 'console-read-2',
afterCreatedAtMs: null,
afterRunId: null,
limit: 32,
};
const response = await request(server.origin, {
method: 'POST',
path: '/api/v1/observe/run-list',
headers,
body,
});
assert.equal(response.statusCode, 200);
assert.deepEqual(requests, [body]);
assert.deepEqual(response.body.result.result, { runs: [], hasMore: false });
assert.equal(Object.hasOwn(requests[0], 'path'), false);
assert.equal(Object.hasOwn(requests[0], 'url'), false);
});
test('returns model text as JSON data only after an explicit output read', async (t) => {
const { server, headers } = await fixture(async (command) => {
assert.equal(command.operation, 'output');
@@ -331,7 +526,10 @@ test('returns model text as JSON data only after an explicit output read', async
response.body.result.result.result.text,
'<script>never execute</script>',
);
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(
response.headers['content-type'],
'application/json; charset=utf-8',
);
assert.equal(response.headers['x-content-type-options'], 'nosniff');
});
@@ -344,7 +542,10 @@ test('masks wrong Host, Origin, session and every non-read route', async (t) =>
t.after(() => server.close());
const cases = [
{ ...headers, origin: 'https://attacker.example' },
{ ...headers, authorization: 'QL3-Console ' + randomBytes(32).toString('base64url') },
{
...headers,
authorization: 'QL3-Console ' + randomBytes(32).toString('base64url'),
},
{ ...headers, host: 'attacker.example' },
];
for (const candidate of cases) {
@@ -18,8 +18,22 @@ const tlsFixture = path.resolve(
packageRoot,
'../ql3-cluster-control/test/fixtures/mtls',
);
const credential =
'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url');
const credential = 'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url');
const consoleOperations = [
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
];
function privateFile(directory, name, contents) {
const filePath = path.join(directory, name);
@@ -189,7 +203,10 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
component: 'qinglong3-cluster-copilot-console',
event: 'process_failed',
});
assert.doesNotMatch(failed.stderr, /client-secret|cluster-secret|browser-secret/);
assert.doesNotMatch(
failed.stderr,
/client-secret|cluster-secret|browser-secret/,
);
});
test('preflight proves private authority and unauthenticated TLS 1.3 readiness', async (t) => {
@@ -214,7 +231,7 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
publishedHostAddress: '127.0.0.1',
browserCredential: 'forbidden',
clusterCredential: 'server_only',
operations: ['inspect', 'output'],
operations: consoleOperations,
mutation: false,
});
assert.deepEqual(value.requests, [
@@ -244,18 +261,19 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
{ cwd: packageRoot, stdio: ['ignore', 'pipe', 'pipe'] },
);
t.after(() => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
if (child.exitCode === null && child.signalCode === null)
child.kill('SIGKILL');
});
const started = JSON.parse(await firstLine(child.stdout));
assert.equal(started.event, 'started');
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.deepEqual(started.operations, ['inspect', 'output']);
assert.deepEqual(started.operations, consoleOperations);
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/);
assert.match(shell.body, /Cluster field ledger/);
child.kill('SIGTERM');
const result = await new Promise((resolve, reject) => {
child.once('error', reject);