From a7ebbe7053267cbcebfdc479d8f3b0ebf9c5637d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:37:55 +0800 Subject: [PATCH 1/4] Document the connected administrator console onboarding --- docs/getting-started/install.md | 11 ++++------- docs/getting-started/quickstart.md | 7 ++++--- docs/web/admin-metrics-backend-requirements.md | 11 +++++++---- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 47e19fa57..6a013e1fc 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -64,9 +64,6 @@ retains that URL for remote node downloads; it does not silently change mirrors. ## Sign in to Web -The management backend and client require the corresponding Web screen migration -before release. See [console integration status](../web/README.md). - Installation creates private configuration under `~/.parsar/core`, a dedicated PostgreSQL volume and a credential encryption key. Installation creates no Project or application API key. Projects and their keys are managed in the database; @@ -79,9 +76,9 @@ password, and keep your sign-in details safe. The Web has one role: administrato with access to every console operation. It has no secondary user roles. The paired console already connects to Core; no API key is needed to sign in. -Use the administrator API to create a Project, then issue a named API key within -it and save the one-time plaintext response privately. Core stores only its digest. -The corresponding Web management screens remain pending. Multiple keys in a Project +Open **Platform → Projects and keys** to create a Project, then issue a named API +key within it and save the one-time plaintext response privately. The administrator +API provides the same operations. Core stores only the key digest. Multiple keys in a Project share its assets and execution principal; writes record the actual key separately. Rotate by issuing another key in that Project and revoking the old one. Archiving the Project disables all its keys and retains assets for inspection, deletion or @@ -131,7 +128,7 @@ management. Choose English or Chinese through the System language selector. ## Add nodes after a default installation -1. Log in to the bundled Web console and open **Hosted Sandbox Manager**. +1. Log in to the bundled Web console and open **Platform → Nodes**. The paired installation needs no second key or Core connection setup. 2. Choose Docker or microsandbox. The paired console address is used automatically. If your network requires a different address for nodes and diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index e2f6a5b66..f79345d77 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -12,11 +12,12 @@ python3 -m venv .venv pip install openai==3.13.0 ``` -The deployment administrator first creates a Project through +The deployment administrator creates a Project and issues a named key in +**Platform → Projects and keys**. The equivalent management API operations are `POST /core/v1/admin/projects` with `{"name":"Default"}`, then issues a key through `POST /core/v1/admin/projects/{project_id}/keys` with a descriptive `{"name":"..."}`. -These requests use the separate deployment credential. The management UI has not -yet migrated; see [integration status](../web/README.md). +These API requests use the separate deployment credential. The console supplies +that credential server-side; it never exposes it to the browser. Obtain that key through a private channel and supply it as `PARSAR_API_KEY` in your application configuration. Its plaintext appears only at issuance; Core stores a diff --git a/docs/web/admin-metrics-backend-requirements.md b/docs/web/admin-metrics-backend-requirements.md index 38b95eb2f..bdcc6b2a9 100644 --- a/docs/web/admin-metrics-backend-requirements.md +++ b/docs/web/admin-metrics-backend-requirements.md @@ -1,7 +1,9 @@ # Administrator metrics: backend requirements -Status: proposal for discussion with Core owners. Nothing in this document is -implemented beyond the Web API routes it names as existing. +Status: remaining aggregate proposals for discussion with Core owners. Core process +metrics are implemented separately at `GET /core/v1/admin/core-metrics`; see the +[measurement contract](../../contracts/agents-api/core-metrics.md). The proposed +endpoints below are not release prerequisites. [简体中文](admin-metrics-backend-requirements.zh-CN.md) The console is a management tool: Monitor (Overview, Agent metrics, Sandbox @@ -79,8 +81,9 @@ This replaces the Item fan-out. The Overview shows Core itself beside its sandbox hosts. Core runs no sandboxes, so it has no slots; the operator asked for its CPU and memory -instead. Nothing reports them, so the console shows the Web API's reachability -and maintenance state and "Not reported" for CPU and memory. +instead. The Core metrics page reports Go memory, service state and database observations +through `/core/v1/admin/core-metrics`. The Overview topology still shows Web API +reachability and maintenance state; host CPU and memory are not reported. `GET /core/v1/admin/core-status` From 455d9e68baa13c443f8d6401329f954000758b6c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:43:04 +0800 Subject: [PATCH 2/4] Exercise management acceptance through the production console --- apps/web/e2e/README.md | 38 ++++++++++ apps/web/e2e/access.spec.ts | 7 +- apps/web/e2e/console.ts | 61 +++++++++++----- apps/web/e2e/data/routes.mjs | 68 ----------------- .../{fixture-console.mjs => fixture-core.mjs} | 73 +++++++------------ apps/web/e2e/fixture-settings.mjs | 2 + apps/web/e2e/monitoring.spec.ts | 18 ++++- apps/web/e2e/nodes.spec.ts | 14 +++- apps/web/e2e/projects.spec.ts | 6 +- apps/web/e2e/proxy-boundary.spec.ts | 31 ++++++++ apps/web/e2e/resources.spec.ts | 2 +- apps/web/e2e/start-console.mjs | 33 +++++++++ docs/web/roadmap.md | 28 ++++--- playwright.config.ts | 44 +++-------- 14 files changed, 239 insertions(+), 186 deletions(-) create mode 100644 apps/web/e2e/README.md rename apps/web/e2e/{fixture-console.mjs => fixture-core.mjs} (77%) create mode 100644 apps/web/e2e/fixture-settings.mjs create mode 100644 apps/web/e2e/proxy-boundary.spec.ts create mode 100644 apps/web/e2e/start-console.mjs diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md new file mode 100644 index 000000000..651fc45ed --- /dev/null +++ b/apps/web/e2e/README.md @@ -0,0 +1,38 @@ +# Administrator console acceptance + +Run from the repository root with the pinned Node, pnpm and Go versions: + +```sh +pnpm test:web:acceptance +``` + +Playwright starts a synthetic Core upstream and builds/runs the production +`services/core-console` binary with freshly built Web assets. Login, cookies, +origin checks, the route allowlist and credential forwarding are production code. +The fixture accepts only the test deployment credential and never handles +`/console/auth` or manufactures browser sessions. Other than the sign-in test, +browser contexts reuse a cookie obtained from real login so the suite respects +the production login rate limit. Its data and writes are +synthetic; this suite is not live cluster or model execution acceptance. + +`GO` may select an absolute Go executable. Set `AGENTS_FIXTURE_PORT` (default +18611) and `AGENTS_WEB_PORT` (default 19619) to unused loopback ports for concurrent +runs. Existing services are never reused. Account state, the private test token, +installer stub, binary and assets live in a fresh `~/.parsar/tests/console-e2e-*` +directory, removed on shutdown. Browser artifacts go to +`~/.parsar/tests/console-playwright`; `AGENTS_E2E_OUTPUT_DIR` selects an independent +output directory. Chrome is the configured Playwright browser. + +The installer stub exercises command creation and the production configuration's +digest only. Tests never execute an enrollment command or install a node. + +The suite retains current management behavior: login, Projects/keys, one-time +secrets, uncertain writes, copy/delete, monitoring, read-only Session history, +node management and failure feedback. The previous builder, execution playground, +resource editors and browser connection-key flows were retired with those UI +features. Public Agents API compatibility remains in the Core/client tests. + +Project isolation, shared application-key access, revocation enforcement, archive +retention, copy transactions, deletion preconditions and audit persistence require +Core backend tests and real deployment acceptance. Fixture responses cannot prove +these invariants. The repository's required `make check` still applies. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 6b66abb08..75a3a9a6d 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -1,11 +1,12 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, resetFixture } from "./console"; +import { expectManagementBoundary, observeBrowser, resetFixture } from "./console"; -test.afterEach(async ({ request }) => expectManagementBoundary(request)); +test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page)); test("creates the administrator, keeps no credential in the browser, and signs out and back in", async ({ page, request }) => { - await resetFixture(request, "setup"); + await resetFixture(request); + observeBrowser(page); await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); await page.goto("/"); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index a9ba7a208..fd3ea3405 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -1,33 +1,60 @@ -import { expect, type APIRequestContext, type Page } from "@playwright/test"; +import { expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test"; -const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; -const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`; +export const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18611}`; +export const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 19619}`; +const browserRequests = new WeakMap>(); +export function observeBrowser(page: Page) { + const calls: Array<{ path: string; authorization: string | undefined }> = []; + browserRequests.set(page, calls); + page.on("request", request => { + const path = new URL(request.url()).pathname; + if (path.startsWith("/core/") || path.startsWith("/v1")) calls.push({ path, authorization: request.headers().authorization }); + }); +} +let authenticatedCookies: Awaited> = []; +export const account = { username: "admin", password: "correct horse battery" }; -/** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */ -export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated") { - await request.post(`${fixture}/__fixture/reset?auth=${auth}`); +/** Reset only synthetic Core data; account state belongs to the real console. */ +export async function resetFixture(request: APIRequestContext) { + expect((await request.post(`${fixture}/__fixture/reset`)).ok()).toBe(true); } -/** Opens the console already signed in, in English. */ +/** Authenticate using the production account endpoint and its real session cookie. */ export async function openConsole(page: Page, request: APIRequestContext, hash = "overview") { - await resetFixture(request, "authenticated"); - await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]); + await resetFixture(request); + observeBrowser(page); + // Reuse a session issued by the real service, avoiding its login rate limit. + // Browser contexts and synthetic Core data are still fresh for each test. + await page.context().addCookies(authenticatedCookies); + const status = await (await page.request.get("/console/auth")).json(); + if (status.mode !== "authenticated") { + const response = await page.request.post(`/console/auth/${status.mode === "setup" ? "setup" : "login"}`, { headers: { Origin: web }, data: account }); + expect(response.status()).toBe(200); + authenticatedCookies = await page.context().cookies(); + } await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); await page.goto(`/#${hash}`); } -/** Makes the next matching write fail once with the given status. */ -export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) { - await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); +/** Inject a Core response, keeping browser and proxy behavior real. */ +export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string; repeat?: boolean }) { + expect((await request.post(`${fixture}/__fixture/fail-next`, { data: failure })).ok()).toBe(true); } -/** Writes the browser sent through the console, as "METHOD /path". */ +export async function requests(request: APIRequestContext) { + return (await (await request.get(`${fixture}/__fixture/requests`)).json()); +} export async function writes(request: APIRequestContext): Promise { - return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes; + return (await requests(request)).writes; } -/** The console never calls /v1 and never sends its own Authorization header. */ -export async function expectManagementBoundary(request: APIRequestContext) { - const { violations } = await (await request.get(`${fixture}/__fixture/requests`)).json(); +/** Every Core request comes from the authenticated console, without browser cookies. */ +export async function expectManagementBoundary(request: APIRequestContext, page: Page) { + for (const call of browserRequests.get(page) ?? []) { + expect(call.path).not.toMatch(/^\/v1(?:\/|$)/); + expect(call.authorization).toBeUndefined(); + } + const { violations, calls } = await requests(request); expect(violations).toEqual([]); + for (const call of calls) expect(call).toMatchObject({ authenticated: true, actor: account.username, cookie: null }); } diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index e89f64e3e..1918200e7 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -95,71 +95,3 @@ export function buildDemo(now = Math.floor(Date.now() / 1000)) { }); return { agents, sessions, turns, items, nodes, allocations, observations }; } - -const list = (data) => ({ object: "list", data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null }); - -export async function installDemoRoutes(page) { - const demo = buildDemo(); - const json = (route, body) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); - await page.route("**/v1/agents?*", (route) => new URL(route.request().url()).pathname === "/v1/agents" ? json(route, list(demo.agents)) : route.fallback()); - await page.route("**/v1/agents", (route) => route.request().method() === "GET" ? json(route, list(demo.agents)) : route.fallback()); - await page.route(/\/v1\/agents\/sessions(\/[^?]*)?(\?.*)?$/, (route) => { - const url = new URL(route.request().url()); - if (url.pathname === "/v1/agents/sessions") { - const agentId = url.searchParams.get("agent_id"); - return json(route, list(agentId ? demo.sessions.filter((session) => session.agent.id === agentId) : demo.sessions)); - } - const match = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)\/(turns|items)$/); - if (match) { - const all = (match[2] === "turns" ? demo.turns : demo.items).get(match[1]) ?? []; - const ordered = url.searchParams.get("order") === "asc" ? all : [...all].reverse(); - return json(route, list(ordered)); - } - const single = url.pathname.match(/^\/v1\/agents\/sessions\/([^/]+)$/); - if (single) { - const session = demo.sessions.find((entry) => entry.id === single[1]); - return session ? json(route, session) : route.fallback(); - } - if (/\/events$/.test(url.pathname)) return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": keepalive\n\n" }); - return route.fallback(); - }); - await page.route("**/v1/agents/runtime-observations*", (route) => json(route, list(demo.observations))); - await page.route("**/v1/agents/runtime-history/capabilities", (route) => json(route, { object: "agent.runtime_history_capabilities", available: true, reason: null, collection_mode: "periodic", sample_interval_seconds: 30, retention_seconds: 604800, minimum_step_seconds: 30, maximum_range_seconds: 86400, maximum_points: 1000, metrics: ["cpu", "memory", "tokens"] })); - await page.route(/\/v1\/agents\/sessions\/[^/]+\/runtime-history/, (route) => { - const url = new URL(route.request().url()); - const sessionId = url.pathname.split("/")[4]; - const observation = demo.observations.find((entry) => entry.session_id === sessionId && entry.mode === "openai_hosted"); - if (!observation) return route.fallback(); - const start = Number(url.searchParams.get("start")); - const end = Number(url.searchParams.get("end")); - const step = Math.max(30, Math.ceil((end - start) / 120 / 30) * 30); - const phase = [...sessionId].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) % 997, 7) % 23; - const points = []; - for (let at = start; at + step <= end; at += step) { - const x = (at - start) / step; - const ratio = Math.max(0.02, Math.min(0.95, 0.35 + 0.25 * Math.sin((x + phase * 5) / 9) + 0.08 * Math.sin(x / 2.3))); - const memory = Math.floor((0.7 + 0.35 * Math.sin((x + phase) / 14)) * 2 ** 30); - const sleeping = observation.lifecycle_state === "sleeping"; - points.push({ start: at, end: at + step, first_observed_at: at + 5, last_observed_at: at + step - 5, observation_count: step / 30, observed_count: sleeping ? 0 : step / 30, unavailable_count: sleeping ? step / 30 : 0, - cpu: sleeping ? null : { contributor_count: 1, utilization_ratio: Number(ratio.toFixed(3)), capacity_cores: 2 }, - memory: sleeping ? null : { contributor_count: 1, usage_bytes: memory, limit_bytes: 2 * 2 ** 30 } }); - } - let input = 50_000 + phase * 1_000; let output = 8_000; - const tokens = points.map((point, index) => { input += 400 + ((index * 37 + phase * 11) % 900); output += 60 + ((index * 13) % 140); return { start: point.start, end: point.end, sampled_at: point.end - 5, input_tokens: input, output_tokens: output }; }); - return json(route, { - object: "agent.runtime_history", source: "durable", session_id: sessionId, - requested_range: { start, end }, resolution_seconds: step, generated_at: end, - coverage: { retained_start: start, first_sample_at: start + 5, last_sample_at: end - 5, sample_count: points.length, expected_sample_count: points.length, - buckets: points.map(({ cpu: _c, memory: _m, ...coverage }) => coverage) }, - series: [{ environment_id: observation.environment_id, allocation_id: observation.instance.allocation_id, started_at: { seconds: start - 600, nanoseconds: 0 }, provider_type: "docker", points }], - token_usage: tokens, - }); - }); - await page.route("**/core/v1/sandbox/deployment", (route) => json(route, { installation_id: "7f3c2a90-demo", provider: "docker", core_url: "https://core.example.internal", maintenance: false, owner_epoch: 3 })); - await page.route("**/core/v1/sandbox/nodes", (route) => json(route, { data: demo.nodes })); - await page.route("**/core/v1/sandbox/nodes/*/allocations", (route) => { - const nodeId = new URL(route.request().url()).pathname.split("/").at(-2); - return json(route, { data: demo.allocations.filter((allocation) => allocation.node_id === nodeId) }); - }); - return demo; -} diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-core.mjs similarity index 77% rename from apps/web/e2e/fixture-console.mjs rename to apps/web/e2e/fixture-core.mjs index 3ce8e6a37..7759c7ab9 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -1,8 +1,5 @@ -// Browser acceptance fixture: the console service's routes (/console/**) and the -// management surfaces it forwards (/core/v1/admin/**, /core/v1/sandbox/**), with -// synthetic, deterministic data and in-memory writes. It never serves /v1; any -// /v1 request, and any browser-supplied Authorization header, is recorded so a -// test can assert that the console stays on its management boundary. +// Synthetic Core upstream behind the real production core-console service. +// No browser authentication or proxy logic belongs in this fixture. import http from "node:http"; import { buildAdmin } from "./data/admin.mjs"; @@ -10,18 +7,17 @@ import { coreMetrics } from "./data/core-metrics.mjs"; import { buildResources } from "./data/resources.mjs"; import { buildDemo } from "./data/routes.mjs"; -const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); -const SESSION_COOKIE = "core_console=fixture-session"; +const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18611); +import { ADMIN_TOKEN } from "./fixture-settings.mjs"; let state; -function reset(mode = "setup") { +function reset() { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); state = { ...base, resources, admin: buildAdmin(now, base, resources), - auth: { mode, username: mode === "authenticated" ? "admin" : null, password: mode === "setup" ? null : "correct horse battery" }, - violations: [], writes: [], failNext: null, nextId: 1, + violations: [], calls: [], metricsUnknown: false, writes: [], failNext: null, nextId: 1, }; } reset(); @@ -61,35 +57,6 @@ const startupConfiguration = { }, }; -async function consoleRoute(request, response, url) { - const auth = state.auth; - if (url.pathname === "/console/auth" && request.method === "GET") { - if (auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE)) return send(response, 200, { mode: "authenticated", username: auth.username }); - return send(response, 200, { mode: auth.mode === "setup" ? "setup" : "login" }); - } - if (url.pathname === "/console/auth/setup" && request.method === "POST") { - if (auth.mode !== "setup") return error(response, 409, "Setup is complete."); - const { username, password } = await body(request); - if (!username || !password || password.length < 12) return error(response, 400, "Invalid administrator."); - Object.assign(auth, { mode: "authenticated", username, password }); - return send(response, 200, { mode: "authenticated", username }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` }); - } - if (url.pathname === "/console/auth/login" && request.method === "POST") { - const { username, password } = await body(request); - if (username !== auth.username || password !== auth.password) return error(response, 401, "Sign-in failed."); - auth.mode = "authenticated"; - return send(response, 200, { mode: "authenticated", username }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` }); - } - if (url.pathname === "/console/auth/logout" && request.method === "POST") { - auth.mode = "login"; - return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); - } - if (url.pathname === "/console/config") { - return send(response, 200, { api_keys: true, sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); - } - return error(response, 404, "Not found."); -} - async function adminWrite(request, response, path) { const a = state.admin; const input = request.method === "POST" ? await body(request) : {}; @@ -111,6 +78,7 @@ async function adminWrite(request, response, path) { const project = match && a.projects.find((entry) => entry.id === match[1]); if (!project) return error(response, 404, "No such project."); const rest = match[2] ?? ""; + if (!rest && request.method === "POST") { project.name = input.name; return send(response, 200, a.publicProject(project)); } if (rest === "/archive" && request.method === "POST") { const at = Math.floor(Date.now() / 1000); project.archived_at = at; @@ -149,7 +117,15 @@ function adminRead(response, path, url) { return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); } if (path === "/startup-configuration") return send(response, 200, startupConfiguration); - if (path === "/core-metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); + if (path === "/core-metrics") { + const value = coreMetrics(url.searchParams.get("range") ?? "1h"); + if (state.metricsUnknown) { + for (const key of ["slots_in_use", "slots_total", "queued_turns", "connected_daemons"]) value.execution[key] = null; + value.database.ping_ms = { p50: null, p95: null }; + value.process.memory_bytes = null; + } + return send(response, 200, value); + } const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/); const project = match && a.projects.find((entry) => entry.id === match[1]); if (!project) return error(response, 404, "No such project."); @@ -208,14 +184,15 @@ async function sandboxRoute(request, response, path) { async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "setup"); + reset(); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { state.failNext = await body(request); // { method, path, status, code?, message? } return send(response, 200, { ok: true }); } - if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes }); + if (url.pathname === "/__fixture/metrics-unknown" && request.method === "POST") { state.metricsUnknown = true; return send(response, 200, {}); } + if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes, calls: state.calls }); return error(response, 404, "Not found."); } @@ -227,15 +204,15 @@ http.createServer(async (request, response) => { state.violations.push(`${request.method} ${url.pathname}`); return error(response, 404, "The console does not serve /v1."); } - if (request.headers.authorization) state.violations.push(`Authorization header on ${request.method} ${url.pathname}`); - if (url.pathname.startsWith("/console/")) return await consoleRoute(request, response, url); - const signedIn = state.auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE); - if (!signedIn) return error(response, 401, "Sign in to the console."); + const call = { method: request.method, path: url.pathname, actor: request.headers["x-core-console-actor"] ?? null, authenticated: request.headers.authorization === `Bearer ${ADMIN_TOKEN}`, cookie: request.headers.cookie ?? null }; + state.calls.push(call); + if (!call.authenticated) { state.violations.push(`Missing deployment credential on ${request.method} ${url.pathname}`); return error(response, 401, "Deployment authentication required."); } + if (call.cookie !== null) state.violations.push("Browser cookie reached Core"); const write = request.method !== "GET" && request.method !== "HEAD"; if (write) state.writes.push(`${request.method} ${url.pathname}`); const fail = state.failNext; if (fail && fail.method === request.method && url.pathname.includes(fail.path)) { - state.failNext = null; + if (!fail.repeat) state.failNext = null; return error(response, fail.status, fail.message ?? "Injected failure.", fail.code ?? null); } if (url.pathname.startsWith("/core/v1/admin/")) { @@ -247,5 +224,5 @@ http.createServer(async (request, response) => { } catch (caught) { error(response, 500, String(caught)); } -}).listen(port, "127.0.0.1", () => console.log(`Console acceptance fixture on http://127.0.0.1:${port}`)); +}).listen(port, "127.0.0.1", () => console.log(`Core upstream fixture on http://127.0.0.1:${port}`)); diff --git a/apps/web/e2e/fixture-settings.mjs b/apps/web/e2e/fixture-settings.mjs new file mode 100644 index 000000000..6a8a672e9 --- /dev/null +++ b/apps/web/e2e/fixture-settings.mjs @@ -0,0 +1,2 @@ +// Test-only credential. The launcher puts it in a private file for core-console. +export const ADMIN_TOKEN = 'acceptance-deployment-admin-only'; diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 7965a09e8..f398b9dc1 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -1,8 +1,8 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { expectManagementBoundary, failNext, openConsole } from "./console"; -test.afterEach(async ({ request }) => expectManagementBoundary(request)); +test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page)); test("shows the deployment's health on Overview and each monitor page", async ({ page, request }) => { await openConsole(page, request, "overview"); @@ -26,3 +26,17 @@ test("opens a Session's conversation from the Session log, read-only", async ({ await expect(page.locator(".chat-row.user").first()).toBeVisible(); await expect(page.getByRole("textbox")).toHaveCount(0); }); + +test('Core metrics keep missing measurements unknown and make a refresh failure visible', async ({ page, request }) => { + await openConsole(page, request, 'core-metrics'); + await expect(page.getByLabel('Core summary')).toBeVisible(); + await request.post(`http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18611}/__fixture/metrics-unknown`); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + const values = page.getByLabel('Core summary').locator('.kpi-value'); + await expect(values).toHaveCount(5); + for (const value of await values.all()) await expect(value).toHaveText('—'); + await failNext(request, { method: 'GET', path: '/core-metrics', status: 503, repeat: true }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('Refresh failed'); + for (const value of await values.all()) await expect(value).toHaveText('—'); +}); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index a53989d17..6c707ddd3 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,8 +1,8 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { expectManagementBoundary, failNext, openConsole } from "./console"; -test.afterEach(async ({ request }) => expectManagementBoundary(request)); +test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page)); test("prepares a one-time node command and removes a node after confirmation", async ({ page, request }) => { await openConsole(page, request, "nodes"); @@ -17,3 +17,13 @@ test("prepares a one-time node command and removes a node after confirmation", a await expect(confirm).toBeHidden(); await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); }); + + +test("failed node reads cannot present the cached hosts as healthy", async ({ page, request }) => { + await openConsole(page, request, "nodes"); + await expect(page.getByRole("table", { name: "Sandbox nodes" })).toContainText("edge-03"); + await failNext(request, { method: "GET", path: "/nodes", status: 503, repeat: true }); + await page.reload(); + await expect(page.getByRole("alert").first()).toBeVisible(); + await expect(page.getByRole("table", { name: "Sandbox nodes" })).toHaveCount(0); +}); diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index 081740edd..ec3909214 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { expectManagementBoundary, failNext, openConsole, writes } from "./console"; -test.afterEach(async ({ request }) => expectManagementBoundary(request)); +test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page)); test("creates a project, shows a new key once, revokes it and archives the project", async ({ page, request }) => { await openConsole(page, request, "projects"); @@ -16,9 +16,13 @@ test("creates a project, shows a new key once, revokes it and archives the proje await page.getByRole("dialog").getByRole("button", { name: "Issue key" }).click(); const issued = page.getByRole("dialog", { name: "Key issued" }); await expect(issued.getByLabel("New key ci")).toHaveValue(/fixture-secret/); + const secret = await issued.getByLabel("New key ci").inputValue(); await issued.getByRole("button", { name: "I've saved this key" }).click(); await expect(page.getByRole("table", { name: "Keys of Acceptance" })).toContainText("ci"); await expect(page.locator("input[readonly]")).toHaveCount(0); + await page.reload(); + await expect(page.getByRole("table", { name: "Keys of Acceptance" })).toContainText("ci"); + expect(await page.content()).not.toContain(secret); expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("fixture-secret"); await page.getByRole("button", { name: "Revoke key ci" }).click(); diff --git a/apps/web/e2e/proxy-boundary.spec.ts b/apps/web/e2e/proxy-boundary.spec.ts new file mode 100644 index 000000000..bc27164ec --- /dev/null +++ b/apps/web/e2e/proxy-boundary.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import { ADMIN_TOKEN } from './fixture-settings.mjs'; +import { account, fixture, openConsole, requests, web } from './console'; + +test('production proxy rejects unauthenticated, public and cross-origin calls and ignores forged browser authority', async ({ page, request }) => { + expect((await page.request.get('/core/v1/admin/projects')).status()).toBe(401); + await openConsole(page, request, 'projects'); + await expect(page.getByRole('heading', { name: 'Projects and keys', exact: true })).toBeVisible(); + const before = (await requests(request)).calls.length; + expect((await page.request.get('/v1/agents', { headers: { Authorization: 'Bearer project-key' } })).status()).toBe(404); + expect((await page.request.post('/core/v1/admin/projects', { headers: { Origin: 'https://foreign.invalid' }, data: { name: 'Denied' } })).status()).toBe(403); + expect((await page.request.post('/core/v1/admin/projects/proj_7f3a91c2/agents', { headers: { Origin: web }, data: {} })).status()).toBe(404); + expect((await requests(request)).calls.length).toBe(before); + const response = await page.request.get('/core/v1/admin/projects', { headers: { Authorization: 'Bearer forged-token', 'X-Core-Console-Actor': 'forged-actor' } }); + expect(response.ok()).toBe(true); + expect((await requests(request)).calls.at(-1)).toMatchObject({ authenticated: true, actor: account.username, cookie: null }); + expect(await response.text()).not.toContain(ADMIN_TOKEN); + expect(await page.content()).not.toContain(ADMIN_TOKEN); + const storage = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage }, cookie: document.cookie })); + expect(storage).not.toContain(ADMIN_TOKEN); + expect(storage).not.toContain('core_console_session'); +}); + +test('authentication read failure leaves private pages unmounted', async ({ page, request }) => { + await request.post(`${fixture}/__fixture/reset`); + await page.route('**/console/auth', route => route.abort('failed')); + await page.goto('/'); + await expect(page.getByText('Could not connect to your console.', { exact: true })).toBeVisible(); + expect((await requests(request)).calls).toEqual([]); + await expect(page.getByRole('navigation', { name: 'Console navigation' })).toHaveCount(0); +}); diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index 85d167fb3..3098d0afd 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { expectManagementBoundary, failNext, openConsole } from "./console"; -test.afterEach(async ({ request }) => expectManagementBoundary(request)); +test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page)); test("copies an Agent into another project and lists it there", async ({ page, request }) => { await openConsole(page, request, "agents"); diff --git a/apps/web/e2e/start-console.mjs b/apps/web/e2e/start-console.mjs new file mode 100644 index 000000000..da7e75426 --- /dev/null +++ b/apps/web/e2e/start-console.mjs @@ -0,0 +1,33 @@ +// Build and serve the production console with private, disposable account state. +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { ADMIN_TOKEN } from './fixture-settings.mjs'; + +const root = join(homedir(), '.parsar', 'tests'); +await mkdir(root, { recursive: true }); +const directory = await mkdtemp(join(root, 'console-e2e-')); +await mkdir(join(directory, 'account'), { mode: 0o700 }); +await writeFile(join(directory, 'admin.key'), ADMIN_TOKEN, { mode: 0o600 }); +await mkdir(join(directory, 'payload')); +// Only the installer download/command contract is exercised; never run this payload. +await writeFile(join(directory, 'payload', 'node-install.pyz'), 'Synthetic installer payload for acceptance only.'); +let child; +let stopping = false; +async function stop() { if (stopping) return; stopping = true; child?.kill('SIGTERM'); } +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, stop); +function run(command, args, env = process.env) { + return new Promise((resolve, reject) => { + child = spawn(command, args, { stdio: 'inherit', env }); + child.on('error', reject); + child.on('exit', code => code === 0 || stopping ? resolve() : reject(new Error(`${command} exited ${code}`))); + }); +} +try { + await run(process.env.GO ?? 'go', ['build', '-o', join(directory, 'console'), './services/core-console']); + const buildArgs = ['--filter', '@agents-core-web/web', 'exec', 'vite', 'build', '--outDir', join(directory, 'dist')]; + if (!stopping) await run(process.env.npm_execpath ? process.execPath : 'pnpm', process.env.npm_execpath ? [process.env.npm_execpath, ...buildArgs] : buildArgs); + const port = process.env.AGENTS_WEB_PORT ?? '19619'; + if (!stopping) await run(join(directory, 'console'), [], { ...process.env, CORE_CONSOLE_ADDR: `127.0.0.1:${port}`, CORE_CONSOLE_ORIGIN: `http://127.0.0.1:${port}`, CORE_CONSOLE_UPSTREAM: `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? '18611'}`, CORE_CONSOLE_ADMIN_TOKEN_FILE: join(directory, 'admin.key'), CORE_CONSOLE_AUTH_MODE: 'account', CORE_CONSOLE_STATE_DIR: join(directory, 'account'), CORE_CONSOLE_DIST: join(directory, 'dist'), CORE_CONSOLE_NODE_PAYLOAD_DIR: join(directory, 'payload') }); +} finally { await rm(directory, { recursive: true, force: true }); } diff --git a/docs/web/roadmap.md b/docs/web/roadmap.md index 4fa305ef3..fa866e1eb 100644 --- a/docs/web/roadmap.md +++ b/docs/web/roadmap.md @@ -26,7 +26,7 @@ contract. Public Agents API compatibility work is tracked in the tooling (`scripts/core-doctor.mjs`, `.env.example`). The console no longer sends `/v1`; remove the path together with that tooling. - Run the browser acceptance below against the production console service and a - real Core; today it runs against a fixture. + real Core; the automated suite uses a synthetic Core upstream. ## Acceptance before calling the UI complete @@ -35,16 +35,22 @@ archive retention, deletion conflicts, copy results and audit attribution throug the production console service. Browser acceptance must also cover denied cross-origin writes, absent `/v1` proxying, secret handling and uncertain write outcomes. -`apps/web/e2e` covers the browser side against `fixture-console.mjs`, a synthetic -console service: administrator setup and sign-in with no credential in browser -storage; Project creation, one-time key display, revocation and archive; an -unconfirmed key issue that is reported and never replayed; a copy's result and the -copy in its target Project; a refused deletion that keeps Core's reason; the -monitor pages and a read-only Session conversation; node enrollment and removal. -Every test also asserts that the browser sent nothing to `/v1` and no -Authorization header. Project isolation, shared key access, audit attribution and -cross-origin write denial are enforced by Core and the console service and are -covered by their backend tests. +`apps/web/e2e` builds and runs the production `services/core-console` with its +real account store, session cookies, host/origin checks and management allowlist. +`fixture-core.mjs` supplies only synthetic Core data and failure responses; it +contains no console authentication or proxy implementation. The suite covers +administrator setup/sign-in, Project creation, one-time key display, revocation +and archive, uncertain key issuance without replay, copies, deletion conflicts, +monitor pages, unknown metrics, read-only Session history, node enrollment/removal +and failed reads. Browser traffic is checked for `/v1` and Authorization headers; +the upstream verifies the server credential, signed-in actor label and absence of +browser cookies. Separate requests exercise rejected cross-origin writes, denied +`/v1` proxying and forged browser credentials/actor labels. + +See the [test guide](../../apps/web/e2e/README.md) for commands and isolation. Core +Project isolation, shared key access, archive retention and transaction/audit +semantics remain covered by Core's backend tests and require real deployment +acceptance; synthetic records do not prove those behaviors or model execution. A backend test pass is evidence for the service it exercises. UI completion requires separate browser evidence for the migrated screens; successful rendering alone is diff --git a/playwright.config.ts b/playwright.config.ts index ad7281828..68019092b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,44 +1,22 @@ import { defineConfig } from "@playwright/test"; +import { homedir } from "node:os"; +import { join } from "node:path"; -const fixturePort = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); -const webPort = Number(process.env.AGENTS_WEB_PORT ?? 4174); -const reuseExistingServer = process.env.AGENTS_REUSE_E2E_SERVERS === "1"; +const fixturePort = Number(process.env.AGENTS_FIXTURE_PORT ?? 18611); +const webPort = Number(process.env.AGENTS_WEB_PORT ?? 19619); +const output = process.env.AGENTS_E2E_OUTPUT_DIR ?? join(homedir(), ".parsar", "tests", "console-playwright"); export default defineConfig({ testDir: "./apps/web/e2e", fullyParallel: false, workers: 1, - timeout: 45_000, + timeout: 30_000, expect: { timeout: 7_500 }, - outputDir: "test-results", - preserveOutput: "always", - reporter: [ - ["line"], - ["html", { open: "never", outputFolder: "playwright-report" }], - ], - use: { - baseURL: `http://127.0.0.1:${webPort}`, - channel: "chrome", - trace: "retain-on-failure", - screenshot: "only-on-failure", - video: "off", - }, + outputDir: join(output, "results"), + reporter: [["line"], ["html", { open: "never", outputFolder: join(output, "report") }]], + use: { baseURL: `http://127.0.0.1:${webPort}`, channel: "chrome", trace: "retain-on-failure", screenshot: "only-on-failure", video: "off" }, webServer: [ - { - command: "node apps/web/e2e/fixture-console.mjs", - url: `http://127.0.0.1:${fixturePort}/__fixture/health`, - reuseExistingServer, - timeout: 15_000, - stdout: "pipe", - stderr: "pipe", - }, - { - command: `AGENTS_API_PROXY_TARGET=http://127.0.0.1:${fixturePort} pnpm --filter @agents-core-web/web exec vite --host 127.0.0.1 --mode test --port ${webPort}`, - url: `http://127.0.0.1:${webPort}`, - reuseExistingServer, - timeout: 30_000, - stdout: "pipe", - stderr: "pipe", - }, + { command: "node apps/web/e2e/fixture-core.mjs", url: `http://127.0.0.1:${fixturePort}/__fixture/health`, timeout: 15_000, reuseExistingServer: false }, + { command: "node apps/web/e2e/start-console.mjs", url: `http://127.0.0.1:${webPort}/healthz`, timeout: 180_000, reuseExistingServer: false, gracefulShutdown: { signal: "SIGTERM", timeout: 5_000 } }, ], }); From e2e0954d621ee43974e885299fd360ca372e2d13 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:44:02 +0800 Subject: [PATCH 3/4] Clarify production console browser acceptance boundary --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 599c4103a..170f71c7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -175,7 +175,7 @@ split oversized components before extending them. Use `internal/obs/log` for log Run `make check` before completion. The standalone gate includes all daemon/shared Go tests, Core contract/client/service tests, Core Web and TypeScript client -checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test +checks (including production-console Playwright acceptance with a synthetic Core upstream), a real dedicated PostgreSQL test database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK tests and packaging, MiniMax companion checks, and Rust filesystem-helper tests/format/Clippy. It intentionally has no product Web/server/installer gates. The full gate fails when the database variable is missing. The test database role From 712be3fad8b6b5bacf130601718ec4de16514889 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:45:09 +0800 Subject: [PATCH 4/4] Allow the installed Chromium channel for server acceptance --- apps/web/e2e/README.md | 3 ++- playwright.config.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 651fc45ed..d9c7af57d 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -21,7 +21,8 @@ runs. Existing services are never reused. Account state, the private test token, installer stub, binary and assets live in a fresh `~/.parsar/tests/console-e2e-*` directory, removed on shutdown. Browser artifacts go to `~/.parsar/tests/console-playwright`; `AGENTS_E2E_OUTPUT_DIR` selects an independent -output directory. Chrome is the configured Playwright browser. +output directory. Chrome is the default Playwright browser; set +`AGENTS_E2E_BROWSER_CHANNEL=chromium` to use the installed bundled Chromium. The installer stub exercises command creation and the production configuration's digest only. Tests never execute an enrollment command or install a node. diff --git a/playwright.config.ts b/playwright.config.ts index 68019092b..c0beac596 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ expect: { timeout: 7_500 }, outputDir: join(output, "results"), reporter: [["line"], ["html", { open: "never", outputFolder: join(output, "report") }]], - use: { baseURL: `http://127.0.0.1:${webPort}`, channel: "chrome", trace: "retain-on-failure", screenshot: "only-on-failure", video: "off" }, + use: { baseURL: `http://127.0.0.1:${webPort}`, channel: process.env.AGENTS_E2E_BROWSER_CHANNEL || "chrome", trace: "retain-on-failure", screenshot: "only-on-failure", video: "off" }, webServer: [ { command: "node apps/web/e2e/fixture-core.mjs", url: `http://127.0.0.1:${fixturePort}/__fixture/health`, timeout: 15_000, reuseExistingServer: false }, { command: "node apps/web/e2e/start-console.mjs", url: `http://127.0.0.1:${webPort}/healthz`, timeout: 180_000, reuseExistingServer: false, gracefulShutdown: { signal: "SIGTERM", timeout: 5_000 } },