Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/.impeccable/surfaces/src-app-tsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ related_targets: ["src/ConsoleApp.tsx"]

Scope: the signed-in console shell and every page behind it, plus the first-run step. Visitor mode: Operate.
Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes.
Constraints: Web API only (`/core/v1/admin`, `/core/v1/sandbox`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark.
Constraints: Web API only (`/core/v1`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark.

Information architecture: Monitor (Overview, Agent metrics, Sandbox metrics, Session log) · Resources (Agents, Environment templates, Skills, Files, Vaults) · Platform (Projects and keys, Nodes, System).

Expand Down
26 changes: 18 additions & 8 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ workbench.
[Core key](../../docs/getting-started/operations.md#core-key)). There are no
console accounts or usernames. The browser sends the key only to sign in and
keeps only the session cookie; the console server holds the Core key and forwards
the Web API (`/core/v1/admin/**`) and sandbox administration
(`/core/v1/sandbox/**`). The console never calls `/v1`.
the Web API (`/core/v1/**`, including sandbox administration under
`/core/v1/sandbox/**`). The console never calls `/v1`.
- The Core key is not an Agents API identity and cannot call `/v1`. An administrator
who wants to call the Agents API issues a project API key like any other caller.
- `/console/config` reports whether sandbox administration is available; without
it the Nodes page explains that it is not configured and the fleet figures show
as unavailable.
- `/console/config` reports the node installer (`node_installer`,
`node_installer_sha256`). Signing in grants administration, so sandbox
administration is available unless the console explicitly reports
`sandbox_admin: false`; then the Nodes page explains that it is not configured
and the fleet figures show as unavailable.
- Chinese and English UI; light and dark themes; reduced motion honored.

## Information Architecture
Expand All @@ -64,7 +66,8 @@ workbench.
duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d),
Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a
sandbox opens in a dialog with its figures and CPU and memory charts), Session log
(every Session, read-only, opening one Session's history).
(every Session, read-only, opening one Session's history; a self-hosted
Session's page also has its environment's executor credentials).
- **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list
shows one project or all projects, with a Project column when all are shown and a
Creator column naming the creating key. Detail pages show the resource's facts
Expand Down Expand Up @@ -106,8 +109,8 @@ workbench.
a key never touches assets. Archiving a project revokes every key and keeps its
assets viewable and deletable. Key plaintext is shown once, at issuance, and never
stored by the console.
- **Web API only.** Every read and write goes through `/core/v1/admin/**` or
`/core/v1/sandbox/**`. The console holds no API key and sends nothing to `/v1`.
- **Web API only.** Every read and write goes through `/core/v1/**`. The console
holds no API key and sends nothing to `/v1`.
- **No asset writes except delete.** Assets are created and changed only by
a project's keys through the Agents API. The console does not create or edit
Agents or Templates, upload Skills or Files, create or replace Credentials, start
Expand All @@ -121,6 +124,13 @@ workbench.
without a record as Unknown.
- **Session history is read-only.** A Session page reads the Session, its Items and
Turns and polls while work is in flight; there is no live event stream.
- **Executor credentials.** Only Core issues the credential file a self-hosted
executor needs, with the deployment's Core key. A Session page whose environment
is self-hosted has an Executor credentials section: issue a credential (shown
once as the credential file, to copy or download, never stored), rotate it (the
old one stops working immediately) or revoke it (the executor can no longer
connect; a running process is not stopped). The file lets one executor connect
for that environment only; it cannot call the Agents API.
- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run,
tool and activity figures are still assembled in the browser from bounded reads
and state their coverage. Metrics that need new Core endpoints are recorded as
Expand Down
5 changes: 3 additions & 2 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ described in [DESIGN.md](DESIGN.md) and its product scope in [PRODUCT.md](PRODUC

## Integration contract

Browser management requests use same-origin `/core/v1/admin` through `AdminClient`,
plus the existing sandbox management client for allowed `/core/v1/sandbox` routes.
Browser management requests use same-origin `/core/v1` through `AdminClient`,
`CoreMetricsClient` (`/core/v1/metrics`) and the sandbox management client
(`/core/v1/sandbox`).
The administrator signs in with the deployment's Core key; the console keeps the key
server-side and gives the browser only a session cookie.
Applications use their own Project keys directly against Core's public `/v1` API.
Expand Down
5 changes: 5 additions & 0 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export async function failNext(request: APIRequestContext, failure: { method: st
await request.post(`${fixture}/__fixture/fail-next`, { data: failure });
}

/** Archives a project behind the console's back, as another administrator would. */
export async function archiveProject(request: APIRequestContext, projectId: string) {
await request.post(`${fixture}/core/v1/projects/${projectId}/archive`, { headers: { cookie: "core_console=fixture-session" } });
}

/** Writes the browser sent through the console, as "METHOD /path". */
export async function writes(request: APIRequestContext): Promise<string[]> {
return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/data/admin.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Synthetic management-plane data (/core/v1/admin/**) for the browser acceptance fixture.
// Synthetic management-plane data (/core/v1/**) for the browser acceptance fixture.
// Projects own isolated assets shared by their named keys; the base demo's
// resources are split across projects so every page can be filtered.
import { agentProject } from "./agents.mjs";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/data/core-metrics.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/admin/core-metrics).
// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/metrics).
const RANGES = { "1h": [60, 60], "6h": [72, 300], "24h": [96, 900], "7d": [84, 7200] };

export function coreMetrics(range = "1h", now = Math.floor(Date.now() / 1000)) {
Expand Down
80 changes: 65 additions & 15 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// 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.
// Core management tree it forwards (/core/v1/**: projects, summary, audit log,
// metrics and /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.
import http from "node:http";

import { buildAdmin } from "./data/admin.mjs";
Expand Down Expand Up @@ -47,6 +48,8 @@ function reset(mode = "login", fresh = false, sandbox = "configured") {
// "authenticated": the console holds a session for the fixture cookie; "login": it holds none.
auth: { mode: mode === "authenticated" ? "authenticated" : "login", failures: 0, lockedUntil: 0 },
violations: [], writes: [], failNext: null, nextId: 1,
// Executor credential metadata by environment ID; tokens are never kept.
executorCredentials: new Map(),
// "none": the deployment is not configured yet, so the Nodes page offers setup.
deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(),
};
Expand All @@ -59,7 +62,10 @@ function send(response, status, body, headers = {}) {
response.end(JSON.stringify(body));
}
function error(response, status, message, code = null) {
send(response, status, { error: { message, type: "invalid_request_error", code, param: null } });
// Derive `type` as Core's writeError does (services/agents-api/internal/api/errors.go).
const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error"
: code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error";
send(response, status, { error: { message, type, code, param: null } });
}
async function body(request) {
const chunks = [];
Expand Down Expand Up @@ -116,6 +122,7 @@ async function consoleRoute(request, response, url) {
return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` });
}
if (url.pathname === "/console/config") {
// Signing in grants administration, so the console reports only its node installer.
return send(response, 200, { node_installer: true, node_installer_sha256: "a".repeat(64) });
}
return error(response, 404, "Not found.");
Expand Down Expand Up @@ -166,13 +173,7 @@ function adminRead(response, path, url) {
const a = state.admin;
if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false });
if (path === "/summary") return send(response, 200, a.summary(url));
if (path === "/runtime-observations") {
// Only E2B reports a sandbox's disk.
const e2b = state.deployment?.provider === "e2b";
const data = a.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } }));
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 === "/core-metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h"));
if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h"));
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.");
Expand Down Expand Up @@ -226,6 +227,12 @@ function nodeDetail(node) {
}

async function sandboxRoute(request, response, path) {
if (path === "/runtime-observations" && request.method === "GET") {
// Only E2B reports a sandbox's disk.
const e2b = state.deployment?.provider === "e2b";
const data = state.admin.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } }));
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 === "/deployment" && request.method === "POST") {
const input = await body(request);
if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict");
Expand Down Expand Up @@ -272,6 +279,47 @@ async function sandboxRoute(request, response, path) {
return error(response, 404, "Not found.");
}

/** A key ID as Core accepts it: a canonical lowercase UUID other than the nil UUID. */
const keyIdValid = (value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value) && value !== "00000000-0000-0000-0000-000000000000";
const EXECUTOR_CREDENTIALS = /^\/projects\/([^/]+)\/environments\/([^/]+)\/executor-credentials(?:\/([^/]+))?$/;

/**
* Executor credentials as Core issues them: only for the self_hosted
* environment of a Session in the project; the token is returned once; an
* existing key_id is reissued only with rotate:true, which also restores a
* revoked one; rotating an unknown key_id is not found; revoking again is safe.
* An archived project keeps listing and revoking but neither issues nor rotates.
*/
async function executorCredentialRoute(request, response, projectId, environmentId, keyId) {
const project = state.admin.projects.find((entry) => entry.id === projectId);
const session = project && state.admin.collections(project.id).sessions.find((entry) => entry.environment.type === "self_hosted" && entry.environment.id === environmentId);
if (!session) return error(response, 404, "No such self-hosted environment.", "not_found_error");
if (!state.executorCredentials.has(environmentId)) state.executorCredentials.set(environmentId, []);
const credentials = state.executorCredentials.get(environmentId);
if (!keyId && request.method === "GET") return send(response, 200, { data: credentials.map((entry) => ({ ...entry })) });
if (!keyId && request.method === "POST") {
// As Core, a body that is not JSON is invalid input like any other: 400 with one message.
const input = await body(request).catch(() => null);
if (!keyIdValid(input?.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "Invalid resource identifier or request limits.", "invalid_request");
if (project.archived_at) return error(response, 409, "The target Project is archived.", "project_archived");
const existing = credentials.find((entry) => entry.key_id === input.key_id);
if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists");
if (!existing && input.rotate === true) return error(response, 404, "No such executor credential.", "not_found_error");
// As Core: rotating restores a revoked key.
if (existing) existing.revoked_at = null;
if (!existing) credentials.push({ key_id: input.key_id, created_at: new Date().toISOString(), revoked_at: null });
return send(response, 201, { key_id: input.key_id, environment_id: environmentId, executor_token: `exec_fixture_${state.nextId++}` });
}
if (keyId && request.method === "DELETE") {
const existing = credentials.find((entry) => entry.key_id === keyId);
if (!existing) return error(response, 404, "No such executor credential.", "not_found_error");
existing.revoked_at ??= new Date().toISOString();
response.writeHead(204, { "cache-control": "no-store" });
return response.end();
}
return error(response, 404, "Not found.");
}

/** Test controls: reset state, inject one failure, and read what the browser sent. */
async function fixtureRoute(request, response, url) {
if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true });
Expand Down Expand Up @@ -308,11 +356,13 @@ http.createServer(async (request, response) => {
state.failNext = null;
return error(response, fail.status, fail.message ?? "Injected failure.", fail.code ?? null);
}
if (url.pathname.startsWith("/core/v1/admin/")) {
const path = url.pathname.slice("/core/v1/admin".length);
if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length));
if (url.pathname.startsWith("/core/v1/")) {
const path = url.pathname.slice("/core/v1".length);
const credentials = path.match(EXECUTOR_CREDENTIALS);
if (credentials) return await executorCredentialRoute(request, response, credentials[1], credentials[2], credentials[3]);
return write ? await adminWrite(request, response, path) : adminRead(response, path, url);
}
if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length));
return error(response, 404, "Not found.");
} catch (caught) {
error(response, 500, String(caught));
Expand Down
Loading
Loading