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
10 changes: 7 additions & 3 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,13 @@ the Parsar mark on a tile with a travelling border beam, and two orbits of
Agents, Sessions, Skills, Vaults, files, templates and machines around it; the
Parsar mark is itself nodes on a ring. Brand copy sits bottom-left in solid
ink; it is a paragraph, not a heading, because the panel's title names the task.
First run is three steps with numbered progress in the panel header: the
administrator account, the first project and its key (shown once; the example
request types itself into a terminal), and a three-chapter tour — Monitor,
Signing in asks for one thing, the deployment's Core key, in a single password
field; where the key is kept and what it can do sit behind a help tip beside the
label, and a refused key, too many attempts or an unavailable console is an error
beside the field. When no project exists yet, first run follows on the same stage
in two steps with numbered progress in the panel header: the first project and its
project API key (shown once; the example request types itself into a terminal),
and a three-chapter tour — Monitor,
Resources, Platform — whose stage shows a real dark screenshot of those pages,
tilted towards the panel. Steps change inside a View Transition: the stage holds
still while the panel slides and blurs across; opening the console dissolves
Expand Down
28 changes: 17 additions & 11 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ workbench.

## Operating Context

- Paired console (`services/core-console`): a single local administrator account
(legacy installations keep Basic authentication). The browser holds only the
console sign-in; the console server holds the deployment administrator
credential and forwards the Web API (`/core/v1/admin/**`) and sandbox
administration (`/core/v1/sandbox/**`). The console never calls `/v1`.
- The console account is not an Agents API identity. An administrator who wants to
call the Agents API issues a key in a project like any other caller.
- Paired console (`services/core-console`): the administrator signs in with the
deployment's Core key, the administration credential the installer writes to
`admin/core.key` under the installation directory (by default
`~/.parsar/core/admin/core.key`; keeping and rotating it is described in
[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 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.
Expand Down Expand Up @@ -80,13 +84,15 @@ workbench.
- **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how
many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox
metrics and Overview; Docker never suspends and shows none of it.
- **First run**: after the administrator account is created and while no project
exists, full-screen steps outside the shell create the first project (default name
`Default`) and its first key, show the plaintext once with an example request,
- **First run**: after signing in with the Core key while no project exists,
full-screen steps outside the shell create the first project (default name
`Default`) and its first project API key, show the plaintext once with an example request,
then give a three-chapter tour of the console (Monitor, Resources, Platform)
before opening it. Signing in uses the same stage.
- Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn,
Skill, Vault, Credential, API key).
Skill, Vault, Credential, API key). The sign-in credential is the Core key
("Core Key"); keys issued in a project for applications are project API keys
("项目 API Key").

## Capabilities and Constraints

Expand Down
3 changes: 2 additions & 1 deletion apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ described in [DESIGN.md](DESIGN.md) and its product scope in [PRODUCT.md](PRODUC

Browser management requests use same-origin `/core/v1/admin` through `AdminClient`,
plus the existing sandbox management client for allowed `/core/v1/sandbox` routes.
The console authenticates the browser and keeps the deployment credential server-side.
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.
The production console returns 404 for `/v1`, even with an explicit Bearer token.

Expand Down
57 changes: 32 additions & 25 deletions apps/web/e2e/access.spec.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,48 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";

import { expectManagementBoundary, resetFixture } from "./console";
import { expectManagementBoundary, FIXTURE_CORE_KEY, resetFixture } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

test("creates the administrator, keeps no credential in the browser, and signs out and back in", async ({ page, request }) => {
await resetFixture(request, "setup");
async function signIn(page: Page, coreKey: string) {
await page.getByLabel("Core key", { exact: true }).fill(coreKey);
await page.getByRole("button", { name: "Sign in" }).click();
}

const browserStorage = (page: Page) => page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }));

test("signs in with the Core key, keeps it out of the browser, and signs out and back in", async ({ page, request }) => {
await resetFixture(request, "login");
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
await page.goto("/");

await expect(page.getByRole("heading", { name: "Create your administrator account" })).toBeVisible();
await page.locator("input[name=username]").fill("admin");
await page.locator("input[name=password]").fill("correct horse battery");
await page.locator("input[name=confirm]").fill("correct horse battery");
await page.getByRole("button", { name: "Create administrator account" }).click();
await expect(page.getByRole("heading", { name: "Sign in to Parsar Core" })).toBeVisible();
await signIn(page, "not-the-core-key");
await expect(page.getByRole("alert")).toHaveText("This Core key is not correct. Check it and try again.");
await signIn(page, FIXTURE_CORE_KEY);
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();
expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY);

const stored = await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }));
expect(stored).not.toContain("correct horse battery");
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page.getByRole("heading", { name: "Sign in to Parsar Core" })).toBeVisible();
await signIn(page, FIXTURE_CORE_KEY);
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();

// Repeated wrong keys: the console says how long to wait, from Retry-After.
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
await page.locator("input[name=username]").fill("admin");
await page.locator("input[name=password]").fill("wrong password!!");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("alert")).toBeVisible();
await page.locator("input[name=password]").fill("correct horse battery");
await page.getByRole("button", { name: "Sign in" }).click();
for (let attempt = 0; attempt < 3; attempt++) await signIn(page, "not-the-core-key");
await expect(page.getByRole("alert")).toHaveText("Too many attempts. Try again in 30 seconds.");
// Failed attempts never lock out the right key.
await signIn(page, FIXTURE_CORE_KEY);
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();
expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY);
});

test("sets up a fresh install: first project, a key shown once, the tour, then the console", async ({ page, request }) => {
await resetFixture(request, "setup", { fresh: true });
test("sets up a fresh install: Core key sign-in, first project, a key shown once, the tour, then the console", async ({ page, request }) => {
await resetFixture(request, "login", { fresh: true });
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
await page.goto("/");
await page.locator("input[name=username]").fill("admin");
await page.locator("input[name=password]").fill("correct horse battery");
await page.locator("input[name=confirm]").fill("correct horse battery");
await page.getByRole("button", { name: "Create administrator account" }).click();
await signIn(page, FIXTURE_CORE_KEY);

await expect(page.getByRole("heading", { name: "Create your first project" })).toBeVisible();
await page.locator("input[name=key-name]").fill("my-app");
Expand All @@ -49,5 +54,7 @@ test("sets up a fresh install: first project, a key shown once, the tour, then t
await expect(page.getByRole("heading", { name: "Is it healthy, and where does it fail?" })).toBeVisible();
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();
expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("fixture-secret");
const stored = await browserStorage(page);
expect(stored).not.toContain("fixture-secret");
expect(stored).not.toContain(FIXTURE_CORE_KEY);
});
7 changes: 5 additions & 2 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { expect, type APIRequestContext, 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}`;

/** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */
export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" | "e2b" } = {}) {
/** The fixture deployment's Core key; the same value as CORE_KEY in fixture-console.mjs. */
export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71";

/** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */
export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" | "e2b" } = {}) {
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}`);
}

Expand Down
57 changes: 38 additions & 19 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { buildDemo } from "./data/routes.mjs";

const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092);
const SESSION_COOKIE = "core_console=fixture-session";
/** The deployment's Core key in this fixture; the same value as FIXTURE_CORE_KEY in console.ts. */
const CORE_KEY = "fixture-core-key-3f9a2c71";
/** Consecutive wrong keys before sign-in is refused for a while, and for how long (seconds). */
const LOCKOUT_AFTER = 3;
const LOCKOUT_SECONDS = 30;

let state;
const hex = (c) => c.repeat(64);
Expand All @@ -28,7 +33,7 @@ function e2bDeployment() {
return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true } };
}

function reset(mode = "setup", fresh = false, sandbox = "configured") {
function reset(mode = "login", fresh = false, sandbox = "configured") {
const base = buildDemo();
const now = Math.floor(Date.now() / 1000);
const resources = buildResources(now, base.agents, base.sessions);
Expand All @@ -37,7 +42,8 @@ function reset(mode = "setup", fresh = false, sandbox = "configured") {
if (fresh) admin.projects.splice(0);
state = {
...base, resources, admin,
auth: { mode, username: mode === "authenticated" ? "admin" : null, password: mode === "setup" ? null : "correct horse battery" },
// "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,
// "none": the deployment is not configured yet, so the Nodes page offers setup.
deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(),
Expand Down Expand Up @@ -71,31 +77,44 @@ function list(all, url, max = 100) {
const id = (prefix) => `${prefix}${String(state.nextId++).padStart(8, "0")}`;
const uuid = () => `00000000-0000-4000-8000-${String(state.nextId++).padStart(12, "0")}`;

/** The console service's authentication errors: `{ "error": message }`. */
function authError(response, status, message, headers = {}) {
send(response, status, { error: message }, headers);
}

/** Sign-in with the Core key, as the console server does it: the key is compared first, so the right key
* always signs in; only wrong keys count, and repeated wrong keys are refused for a while. */
async function login(request, response) {
const auth = state.auth;
if (!request.headers["content-type"]?.startsWith("application/json")) return authError(response, 415, "Use application/json");
let input;
try { input = await body(request); } catch { return authError(response, 400, "Invalid authentication request"); }
if (typeof input?.core_key !== "string" || !input.core_key) return authError(response, 400, "Invalid authentication request");
if (input.core_key !== CORE_KEY) {
const wait = Math.ceil((auth.lockedUntil - Date.now()) / 1000);
if (wait > 0) return authError(response, 429, "Too many authentication attempts", { "retry-after": String(wait) });
auth.failures += 1;
if (auth.failures < LOCKOUT_AFTER) return authError(response, 401, "Invalid Core key");
auth.lockedUntil = Date.now() + LOCKOUT_SECONDS * 1000;
return authError(response, 429, "Too many authentication attempts", { "retry-after": String(LOCKOUT_SECONDS) });
}
Object.assign(auth, { mode: "authenticated", failures: 0 });
return send(response, 200, { mode: "authenticated" }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` });
}

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` });
const signedIn = auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE);
return send(response, 200, { mode: signedIn ? "authenticated" : "login" });
}
if (url.pathname === "/console/auth/login" && request.method === "POST") return login(request, response);
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 send(response, 200, { node_installer: true, node_installer_sha256: "a".repeat(64) });
}
return error(response, 404, "Not found.");
}
Expand Down Expand Up @@ -245,7 +264,7 @@ 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", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured");
reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured");
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/fail-next" && request.method === "POST") {
Expand Down
17 changes: 2 additions & 15 deletions apps/web/src/ConsoleApp.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

import { AppearanceMenu } from "./components/AppearanceMenu";
import { ConsoleSidebar } from "./components/ConsoleSidebar";
import { FirstProjectSetup } from "./features/api-keys/FirstProjectSetup";
import { ProjectsPage } from "./features/api-keys/ProjectsPage";
import { AgentsPage } from "./features/agents/AgentsPage";
import { ConsoleAccountMenu, useConsoleAccount } from "./features/first-run/ConsoleAccess";
import { OnboardingLayout } from "./features/onboarding/OnboardingLayout";
import { TemplatesPage } from "./features/environment-templates/TemplatesPage";
import { FilesPage } from "./features/files/FilesPage";
import { AgentMetricsPage } from "./features/metrics/AgentMetricsPage";
Expand Down Expand Up @@ -63,7 +60,6 @@ function ConsolePage({ view }: { view: ConsoleView }) {
function ConsoleShell() {
const { t } = useTranslation("navigation");
const { state } = useProjects();
const account = useConsoleAccount();
const [location, setLocation] = useState(readLocation);
const [setupDone, setSetupDone] = useState(false);
// Once first-run setup has started it stays until it finishes: a background
Expand Down Expand Up @@ -106,17 +102,8 @@ function ConsoleShell() {
for (const project of state.projects) void queryClient.prefetchQuery(collectionQuery(spec, project.id));
}, [state.projects]);

// First run: an administrator with no project yet creates the first one and its key.
// Just after the administrator account was created, the projects are still
// loading: keep the onboarding stage rather than flashing the console.
if (account?.fresh && !setupDone && !setupStarted && state.status === "loading" && state.projects.length === 0) {
return (
<OnboardingLayout scene="project" step="project" controls={<><ConsoleAccountMenu /><AppearanceMenu /></>}>
<p className="onboarding-preparing" role="status">{t("preparing", { ns: "onboarding" })}</p>
</OnboardingLayout>
);
}

// First run: with no project yet, the administrator creates the first one and
// its API key. Signing in reads the projects first, so this opens at once.
if ((needsSetup || setupStarted) && !setupDone) {
return <FirstProjectSetup onDone={() => setSetupDone(true)} />;
}
Expand Down
Loading