From 7f46e0b4963159e20e099f61f15a2092ec5e3a12 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:18:57 +0000 Subject: [PATCH 1/4] Sign in to the console with the Core key --- apps/web/DESIGN.md | 10 +- apps/web/PRODUCT.md | 28 +++-- apps/web/README.md | 3 +- apps/web/e2e/access.spec.ts | 56 +++++---- apps/web/e2e/console.ts | 7 +- apps/web/e2e/fixture-console.mjs | 54 +++++--- apps/web/src/ConsoleApp.tsx | 17 +-- .../src/features/first-run/ConsoleAccess.tsx | 119 +++++++++--------- apps/web/src/features/first-run/auth.test.ts | 34 ++--- apps/web/src/features/first-run/auth.ts | 58 +++++---- .../src/features/first-run/console-access.css | 2 - .../features/onboarding/OnboardingLayout.tsx | 8 +- .../features/onboarding/OnboardingStage.tsx | 2 +- .../src/features/onboarding/onboarding.css | 10 +- apps/web/src/i18n/locales/en/keys.ts | 6 +- apps/web/src/i18n/locales/en/onboarding.ts | 9 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 6 +- apps/web/src/i18n/locales/zh-CN/onboarding.ts | 11 +- apps/web/src/lib/console-auth-strings.ts | 26 ++-- docs/web/architecture.md | 2 +- docs/web/core-connection.md | 24 ++-- docs/web/protocol-coverage.md | 8 +- docs/web/roadmap.md | 10 +- 23 files changed, 267 insertions(+), 243 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 78e0ebfe3..3e4642b23 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -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 diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 2f9ddc4f3..f61f8f0cd 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -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. @@ -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 diff --git a/apps/web/README.md b/apps/web/README.md index d50e3b07a..bc2db8a6c 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 1be37409b..cec15d4ff 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -1,43 +1,45 @@ -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(); - - const stored = await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage })); - expect(stored).not.toContain("correct horse battery"); + expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); 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(); + 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(); + 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."); + 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"); @@ -49,5 +51,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); }); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 10eda2875..b12ed654c 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -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"}`); } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 225669eac..572c7b8f9 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -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); @@ -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); @@ -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(), @@ -71,25 +77,37 @@ 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: a wrong key is refused, and repeated wrong keys lock sign-in 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"); + const wait = Math.ceil((auth.lockedUntil - Date.now()) / 1000); + if (wait > 0) return authError(response, 429, "Too many authentication attempts", { "retry-after": String(wait) }); + if (input.core_key !== CORE_KEY) { + 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` }); @@ -245,7 +263,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") { diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index 758cc591e..ec05a8503 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -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"; @@ -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 @@ -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 ( - }> -

{t("preparing", { ns: "onboarding" })}

-
- ); - } - + // 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 setSetupDone(true)} />; } diff --git a/apps/web/src/features/first-run/ConsoleAccess.tsx b/apps/web/src/features/first-run/ConsoleAccess.tsx index 8d768cf24..d8abc817e 100644 --- a/apps/web/src/features/first-run/ConsoleAccess.tsx +++ b/apps/web/src/features/first-run/ConsoleAccess.tsx @@ -5,17 +5,20 @@ import { HelpTip } from "../../components/console-ui"; import { ThemeMenu } from "../../components/ThemeMenu"; import { useToast } from "../../components/Toast"; import { setLanguage } from "../../i18n"; +import { projectsQuery, queryClient } from "../../lib/queries"; import { OnboardingLayout } from "../onboarding/OnboardingLayout"; import { withTransition } from "../onboarding/view-transition"; import { changeConsoleAuth, ConsoleAuthError, readConsoleAuth, type ConsoleAuth } from "./auth"; import "./console-access.css"; -const ConsoleAccountContext = createContext<{ - username: string; - logout: () => Promise; - /** The administrator account was created in this page: first-run setup follows. */ - fresh: boolean; -} | null>(null); +/** + * Where the installer writes the Core key: its file inside the installation + * directory, and that file under the default installation directory. The + * sign-in help names both. + */ +const CORE_KEY_LOCATION = { file: "admin/core.key", defaultPath: "~/.parsar/core/admin/core.key" } as const; + +const ConsoleAccountContext = createContext<{ logout: () => Promise } | null>(null); export const useConsoleAccount = () => useContext(ConsoleAccountContext); export function ConsoleLanguage() { @@ -33,30 +36,35 @@ export function ConsoleAccountMenu() { const toast = useToast(); if (!account) return null; return
-
; } +/** True when the deployment has no project yet, so first-run setup follows signing in. */ +async function needsFirstProject(): Promise { + try { + return (await queryClient.fetchQuery(projectsQuery)).length === 0; + } catch { + return false; // The console reports the failed read itself. + } +} + export function ConsoleAccess({ children }: { children: ReactNode }) { const { t } = useTranslation("firstRun"); const [status, setStatus] = useState(null); const [failed, setFailed] = useState(false); const [revision, setRevision] = useState(0); - const accountExpected = useRef(false); - const [fresh, setFresh] = useState(false); const generation = useRef(0); const refresh = useCallback(() => setRevision((current) => current + 1), []); useEffect(() => { const controller = new AbortController(); const current = ++generation.current; setFailed(false); - void readConsoleAuth(controller.signal, accountExpected.current).then((value) => { - if (generation.current !== current) return; - if (value.mode !== "legacy") accountExpected.current = true; - setStatus(value); + void readConsoleAuth(controller.signal).then((value) => { + if (generation.current === current) setStatus(value); }).catch(() => { if (!controller.signal.aborted && generation.current === current) setFailed(true); }); return () => { controller.abort(); generation.current++; }; }, [revision]); @@ -67,83 +75,78 @@ export function ConsoleAccess({ children }: { children: ReactNode }) { window.addEventListener("focus", check); return () => { window.clearInterval(timer); window.removeEventListener("focus", check); }; }, [status?.mode, refresh]); - if (status?.mode === "legacy") return children; - if (status?.mode === "authenticated") return { - const next = await changeConsoleAuth("logout", {}); + if (status?.mode === "authenticated") return { + const next = await changeConsoleAuth({ action: "logout" }); generation.current++; - setFresh(false); setStatus(next); } }}>{children}; - const setup = status?.mode === "setup"; - return }> - {status && !failed ? { - // Setup continues to the first project on the same stage; signing in reveals the console. - withTransition(setup ? "step" : "enter", () => { + return }> + {status && !failed ? { + // Without a project, first-run setup continues on the same stage; otherwise the console opens. + withTransition(firstRun ? "step" : "enter", () => { generation.current++; - setFresh(setup); setStatus(next); }, from); - }} onRefresh={refresh} /> : + }} /> :

{t(failed ? "Could not connect to your console." : "Connecting to your console…")}

{failed ? : null}
}
; } -function AccountForm({ setup, onAuthenticated, onRefresh }: { - setup: boolean; onAuthenticated: (status: ConsoleAuth, from: HTMLElement | null) => void; onRefresh: () => void; +function CoreKeyForm({ onAuthenticated }: { + onAuthenticated: (status: ConsoleAuth, firstRun: boolean, from: HTMLElement | null) => void; }) { const { t } = useTranslation("firstRun"); const id = useId(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const [uncertain, setUncertain] = useState(false); const pending = useRef(false); + const input = useRef(null); const controller = useRef(null); useEffect(() => () => controller.current?.abort(), []); + + function errorMessage(cause: unknown): string { + const status = cause instanceof ConsoleAuthError ? cause.status : 0; + if (status === 401) return t("This Core key is not correct. Check it and try again."); + if (status === 429) { + const seconds = cause instanceof ConsoleAuthError ? cause.retryAfterSeconds : null; + return seconds && seconds > 1 ? t("Too many attempts. Try again in {{seconds}} seconds.", { seconds }) : t("Too many attempts. Wait a moment before trying again."); + } + if (status === 503) return t("The console cannot check the key right now. Try again later."); + return t("Could not sign in. Try again."); + } + async function submit(event: FormEvent) { event.preventDefault(); - if (pending.current || uncertain) return; - const data = new FormData(event.currentTarget); + if (pending.current) return; const submitter = event.nativeEvent instanceof SubmitEvent && event.nativeEvent.submitter instanceof HTMLElement ? event.nativeEvent.submitter : null; - const password = String(data.get("password") ?? ""); - if (setup && password !== data.get("confirm")) { setError(t("Passwords do not match.")); return; } - const bytes = new TextEncoder().encode(password).length; - if (setup && (bytes < 12 || bytes > 72)) { setError(t("Use a password between 12 and 72 bytes.")); return; } + const coreKey = String(new FormData(event.currentTarget).get("core-key") ?? "").trim(); + if (!coreKey) return; pending.current = true; setBusy(true); setError(null); const request = new AbortController(); controller.current = request; try { - const next = await changeConsoleAuth(setup ? "setup" : "login", { - username: String(data.get("username") ?? ""), password, - }, request.signal); - if (!request.signal.aborted) { - onAuthenticated(next, submitter); - } + const next = await changeConsoleAuth({ action: "login", coreKey }, request.signal); + const firstRun = await needsFirstProject(); + if (!request.signal.aborted) onAuthenticated(next, firstRun, submitter); } catch (cause) { if (request.signal.aborted) return; - const status = cause instanceof ConsoleAuthError ? cause.status : 0; - if (status === 401 || status === 400) setError(t(setup ? "Check the account details and try again." : "Check your sign-in details and try again.")); - else if (status === 429) setError(t("Too many attempts. Wait a moment before trying again.")); - else { - setUncertain(true); - setError(t(status === 409 ? "An administrator already exists. Sign in to continue." : "Could not confirm the result. Check the account status before trying again.")); - } + setError(errorMessage(cause)); + input.current?.select(); } finally { pending.current = false; if (!request.signal.aborted) setBusy(false); } } + return
void submit(event)}> -

{t(setup ? "Create your administrator account" : "Welcome back")}

- {/* Rules sit behind help tips beside their labels, not as small print under the fields. */} -
- {setup ? {t("Letters, numbers, dots, underscores and hyphens.")} : null} - -
+

{t("Sign in to Parsar Core")}

+ {/* Where the key is and what it can do sit behind the help tip, not as small print under the field. */}
- {setup ? {t("12–72 bytes. Keep this password somewhere safe.")} : null} - + + {t("This deployment's Core key. The installer saved it in a private file: {{file}} in the installation directory, by default {{defaultPath}}. It is an administration credential: it cannot call the /v1 Agents API, and the console never keeps it in your browser.", CORE_KEY_LOCATION)} + {/* Read-only rather than disabled while signing in, so a refused key can be selected for correction. */} +
- {setup ? : null} - {error ?

{error}

: null} - {uncertain ? : - } + {error ? : null} +
; } diff --git a/apps/web/src/features/first-run/auth.test.ts b/apps/web/src/features/first-run/auth.test.ts index 69f905e8e..74604e876 100644 --- a/apps/web/src/features/first-run/auth.test.ts +++ b/apps/web/src/features/first-run/auth.test.ts @@ -1,27 +1,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { changeConsoleAuth, parseConsoleAuth, readConsoleAuth } from "./auth"; +import { changeConsoleAuth, ConsoleAuthError, parseConsoleAuth, readConsoleAuth } from "./auth"; afterEach(() => vi.unstubAllGlobals()); describe("console authentication boundary", () => { - it("accepts only finite modes and returns no additional server fields", () => { - expect(parseConsoleAuth({ mode: "authenticated", username: "admin", token: "secret" })).toEqual({ mode: "authenticated", username: "admin" }); - for (const value of [null, {}, { mode: "admin" }, { mode: "authenticated" }, { mode: "authenticated", username: "" }]) expect(() => parseConsoleAuth(value)).toThrow(); + it("accepts only the login and authenticated modes and keeps no other server field", () => { + expect(parseConsoleAuth({ mode: "authenticated", username: "admin", token: "secret" })).toEqual({ mode: "authenticated" }); + expect(parseConsoleAuth({ mode: "login" })).toEqual({ mode: "login" }); + for (const value of [null, {}, { mode: "admin" }, { mode: "setup" }, { mode: "legacy" }]) expect(() => parseConsoleAuth(value)).toThrow(); }); - it("allows the legacy static response only before account mode is established", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("", { headers: { "content-type": "text/html" } }))); - expect(await readConsoleAuth()).toEqual({ mode: "legacy" }); - await expect(readConsoleAuth(undefined, true)).rejects.toThrow(); - }); - it("does not turn transport, invalid JSON or explicit mode downgrade into legacy access", async () => { - for (const response of [new Response("", { status: 503 }), new Response("{}", { headers: { "content-type": "application/json" } }), new Response('{"mode":"legacy"}')]) { + it("treats an HTML page, a failed read or invalid JSON as a failure, never as access", async () => { + for (const response of [new Response("", { headers: { "content-type": "text/html" } }), new Response("", { status: 404 }), new Response("", { status: 503 }), new Response("{}")]) { vi.stubGlobal("fetch", vi.fn(async () => response)); - await expect(readConsoleAuth(undefined, true)).rejects.toThrow(); + await expect(readConsoleAuth()).rejects.toBeInstanceOf(ConsoleAuthError); } }); - it("sends credentials only in a same-origin JSON POST, not in the URL", async () => { - const fetcher = vi.fn(async () => new Response('{"mode":"authenticated","username":"admin"}')); + it("sends the Core key only in a same-origin JSON POST body", async () => { + const fetcher = vi.fn(async () => new Response('{"mode":"authenticated"}')); vi.stubGlobal("fetch", fetcher); - await changeConsoleAuth("login", { username: "admin", password: "private" }); - expect(fetcher.mock.calls[0]).toEqual(["/console/auth/login", expect.objectContaining({ method: "POST", credentials: "same-origin", body: '{"username":"admin","password":"private"}' })]); + expect(await changeConsoleAuth({ action: "login", coreKey: "private" })).toEqual({ mode: "authenticated" }); + expect(fetcher.mock.calls[0]).toEqual(["/console/auth/login", expect.objectContaining({ method: "POST", credentials: "same-origin", body: '{"core_key":"private"}' })]); + }); + it("reports the status of a refused sign-in and how long to wait after too many attempts", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response('{"error":"Too many attempts"}', { status: 429, headers: { "retry-after": "42" } }))); + await expect(changeConsoleAuth({ action: "login", coreKey: "k" })).rejects.toMatchObject({ status: 429, retryAfterSeconds: 42 }); + vi.stubGlobal("fetch", vi.fn(async () => new Response('{"error":"Invalid Core key"}', { status: 401 }))); + await expect(changeConsoleAuth({ action: "login", coreKey: "k" })).rejects.toMatchObject({ status: 401, retryAfterSeconds: null }); }); }); diff --git a/apps/web/src/features/first-run/auth.ts b/apps/web/src/features/first-run/auth.ts index aebbecb2a..7266beb09 100644 --- a/apps/web/src/features/first-run/auth.ts +++ b/apps/web/src/features/first-run/auth.ts @@ -1,35 +1,49 @@ -export type ConsoleAuth = { mode: "legacy" | "setup" | "login" } | { mode: "authenticated"; username: string }; +/** Whether the browser holds a console session. The console has one administrator and no usernames. */ +export type ConsoleAuth = { mode: "login" } | { mode: "authenticated" }; export class ConsoleAuthError extends Error { - constructor(readonly status: number) { super("Console authentication failed."); } + constructor(readonly status: number, readonly retryAfterSeconds: number | null = null) { super("Console authentication failed."); } } export function parseConsoleAuth(value: unknown): ConsoleAuth { if (!value || typeof value !== "object" || !("mode" in value)) throw new ConsoleAuthError(502); - if (value.mode === "legacy" || value.mode === "setup" || value.mode === "login") return { mode: value.mode }; - if (value.mode === "authenticated" && "username" in value && typeof value.username === "string" && value.username.length > 0) { - return { mode: "authenticated", username: value.username }; - } + if (value.mode === "login" || value.mode === "authenticated") return { mode: value.mode }; throw new ConsoleAuthError(502); } -export async function readConsoleAuth(signal?: AbortSignal, accountExpected = false): Promise { - const response = await fetch("/console/auth", { credentials: "same-origin", cache: "no-store", signal }); - // Older consoles and the standalone development server serve the SPA here. - if (!accountExpected && (response.status === 404 || response.ok && response.headers.get("content-type")?.includes("text/html"))) { - return { mode: "legacy" }; +/** Seconds to wait from a `Retry-After` header, given as seconds or as an HTTP date. */ +export function retryAfterSeconds(header: string | null, now = Date.now()): number | null { + const value = header?.trim(); + if (!value) return null; + if (/^\d+$/.test(value)) return Number(value); + const at = Date.parse(value); + return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000)); +} + +async function readStatus(response: Response): Promise { + if (!response.ok) throw new ConsoleAuthError(response.status, response.status === 429 ? retryAfterSeconds(response.headers.get("retry-after")) : null); + try { + return parseConsoleAuth(await response.json()); + } catch { + throw new ConsoleAuthError(502); } - if (!response.ok) throw new ConsoleAuthError(response.status); - const status = parseConsoleAuth(await response.json()); - if (accountExpected && status.mode === "legacy") throw new ConsoleAuthError(502); - return status; } -export async function changeConsoleAuth(action: "setup" | "login" | "logout", body: Record, signal?: AbortSignal): Promise { - const response = await fetch(`/console/auth/${action}`, { - method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), signal, - }); - if (!response.ok) throw new ConsoleAuthError(response.status); - return parseConsoleAuth(await response.json()); +export async function readConsoleAuth(signal?: AbortSignal): Promise { + return readStatus(await fetch("/console/auth", { credentials: "same-origin", cache: "no-store", signal })); +} + +/** + * Signs in with the deployment's Core key, or signs out. The key travels only + * in this same-origin JSON body; the console server answers with a session + * cookie and the browser never stores the key. + */ +export async function changeConsoleAuth( + change: { action: "login"; coreKey: string } | { action: "logout" }, + signal?: AbortSignal, +): Promise { + return readStatus(await fetch(`/console/auth/${change.action}`, { + method: "POST", credentials: "same-origin", cache: "no-store", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(change.action === "login" ? { core_key: change.coreKey } : {}), signal, + })); } diff --git a/apps/web/src/features/first-run/console-access.css b/apps/web/src/features/first-run/console-access.css index 519a4cbeb..87b350874 100644 --- a/apps/web/src/features/first-run/console-access.css +++ b/apps/web/src/features/first-run/console-access.css @@ -5,9 +5,7 @@ .console-auth-form .field { gap: 7px; } .console-auth-form .field > span { font-size: 12px; } .console-auth-form .field input { padding: 10px 12px; min-height: 42px; background: var(--surface-subtle); } -.console-auth-form .field small { font-size: 11px; line-height: 1.6; color: var(--fg-muted); } .console-auth-form .button { min-height: 42px; justify-content: center; gap: 12px; } .console-auth-form .console-auth-error { color: var(--danger); } .console-account-menu > button { background: transparent; border: 0; display: flex; white-space: nowrap; gap: 9px; align-items: center; padding: 8px; color: var(--fg-muted); font: inherit; font-size: 12px; cursor: pointer; } -.console-account-menu small { color: var(--danger); } .console-account-menu > button:hover { color: var(--fg); } diff --git a/apps/web/src/features/onboarding/OnboardingLayout.tsx b/apps/web/src/features/onboarding/OnboardingLayout.tsx index 84f5df8e6..50f7c5229 100644 --- a/apps/web/src/features/onboarding/OnboardingLayout.tsx +++ b/apps/web/src/features/onboarding/OnboardingLayout.tsx @@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next"; import { OnboardingStage, type StageScene, type TourChapter } from "./OnboardingStage"; import "./onboarding.css"; -export type OnboardingStep = "account" | "project" | "tour"; -const STEPS: readonly OnboardingStep[] = ["account", "project", "tour"]; +export type OnboardingStep = "project" | "tour"; +const STEPS: readonly OnboardingStep[] = ["project", "tour"]; /** * Every onboarding screen: the dark stage on the left, the step's panel on @@ -14,12 +14,12 @@ const STEPS: readonly OnboardingStep[] = ["account", "project", "tour"]; * (no `step`) uses the same frame without the progress. */ export function OnboardingLayout({ scene, chapter, step, controls, children }: { - /** What the stage shows; null while the console is still checking the account. */ + /** What the stage shows; null while the console is still checking the sign-in. */ scene: StageScene | "tour" | null; chapter?: TourChapter; /** The first-run step this screen is; omitted when signing in. */ step?: OnboardingStep; - /** Theme, language and account controls. */ + /** Theme, language and sign-out controls. */ controls: ReactNode; children: ReactNode; }) { diff --git a/apps/web/src/features/onboarding/OnboardingStage.tsx b/apps/web/src/features/onboarding/OnboardingStage.tsx index ada8064cb..45d9b6117 100644 --- a/apps/web/src/features/onboarding/OnboardingStage.tsx +++ b/apps/web/src/features/onboarding/OnboardingStage.tsx @@ -9,7 +9,7 @@ import { FlickeringGrid } from "@/components/magicui/flickering-grid"; import { LightRays } from "@/components/magicui/light-rays"; import { OrbitingCircles } from "@/components/magicui/orbiting-circles"; -export type StageScene = "account" | "login" | "project"; +export type StageScene = "login" | "project"; export type TourChapter = "monitor" | "resources" | "platform"; const EASE = [0.16, 1, 0.3, 1] as const; diff --git a/apps/web/src/features/onboarding/onboarding.css b/apps/web/src/features/onboarding/onboarding.css index 6b35f9fcd..2682878e5 100644 --- a/apps/web/src/features/onboarding/onboarding.css +++ b/apps/web/src/features/onboarding/onboarding.css @@ -1,5 +1,6 @@ /* - * Onboarding: administrator setup, the first project and key, and the tour. + * Onboarding: signing in with the Core key, the first project and its API key, + * and the tour. * A dark stage (always dark, whatever the theme) holds the brand moment — Core * as the Parsar mark with its orbits, or a screenshot of the console during * the tour; the panel beside it follows the console theme and holds the task. @@ -282,7 +283,7 @@ color: #fff; } -/* The account form and the first project screens inside the panel. */ +/* The sign-in form and the first project screens inside the panel. */ .onboarding-panel .console-auth-form, .onboarding-panel .first-key-column { width: 100%; @@ -298,11 +299,6 @@ letter-spacing: -0.02em; } -.onboarding-preparing { - color: var(--ink-3); - font-size: 13px; -} - /* The example request types itself into a terminal. */ .onboarding-terminal { position: relative; diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 89cd27df9..a747002cc 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -1,7 +1,7 @@ export const keys = { page: { title: "Projects and keys", - help: "A project owns a set of assets: Agents, Sessions, Skills, templates, files and Vaults. Every API key of a project sees and uses the same assets; projects are isolated from each other. Archiving a project revokes all of its keys and keeps its assets.", + help: "A project owns a set of assets: Agents, Sessions, Skills, templates, files and Vaults. Every API key of a project sees and uses the same assets; projects are isolated from each other. Archiving a project revokes all of its keys and keeps its assets. Applications call the Agents API (/v1) with these project API keys; the Core key you sign in with is separate and is not listed here.", create: "Create project", refresh: "Refresh projects", loading: "Loading projects…", @@ -194,12 +194,12 @@ export const keys = { }, firstRun: { title: "Create your first project", - lead: "A project holds Agents, Sessions and files. Callers reach it with an API key.", + lead: "A project holds Agents, Sessions and files. Applications reach it with a project API key.", projectName: "Project name", submit: "Create project and key", submitting: "Creating…", projectCreated: "Project {{project}} was created. Issue its key to continue.", - readyTitle: "Your API key is ready", + readyTitle: "Your project API key is ready", keyCaption: "{{key}} · {{project}}", tryIt: "Try it", tryItHelp: "Run this in a terminal with PROJECT_API_KEY set to the project API key above. It lists the project's Agents; a new project has none yet. The console does not send this request.", diff --git a/apps/web/src/i18n/locales/en/onboarding.ts b/apps/web/src/i18n/locales/en/onboarding.ts index 3703acc38..c46c11175 100644 --- a/apps/web/src/i18n/locales/en/onboarding.ts +++ b/apps/web/src/i18n/locales/en/onboarding.ts @@ -1,17 +1,14 @@ export const onboarding = { steps: { label: "Setup progress", - account: "Administrator", - project: "Project and key", + project: "Project and API key", tour: "Your console", }, stage: { - account: { title: "One Core. Many Agents.", body: "Connect your machines, run your Agents, and watch every Session from one console." }, - login: { title: "Welcome back to your Core.", body: "Your Agents, Sessions and machines, where you left them." }, + login: { title: "One Core. Many Agents.", body: "Connect your machines, run your Agents, and watch every Session from one console." }, project: { title: "A project holds the work.", body: "Agents, Sessions, Skills, files and Vaults belong to a project. Applications reach it with the project's API keys." }, orbit: "Core and what it manages: Agents, Sessions, Skills, Vaults, files, templates and machines", }, - preparing: "Preparing your console…", terminal: "Try it in a terminal", tour: { eyebrow: "Your console · {{n}} of {{total}}", @@ -43,7 +40,7 @@ export const onboarding = { name: "Platform", title: "The deployment itself.", points: [ - "Projects and keys: create projects, issue keys once, revoke or archive.", + "Projects and keys: create projects, issue project API keys shown once, revoke or archive.", "Nodes: add your machines with one command and watch their capacity.", "System: the Core build and how it was started.", ], diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 29c98d477..1cd5f7af2 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -3,7 +3,7 @@ type TranslationShape = { [K in keyof T]: T[K] extends string ? string : Tran export const keys: TranslationShape = { page: { title: "项目与 key", - help: "项目拥有一组资产:Agent、Session、Skill、环境模板、文件和 Vault。同一个项目的所有 API key 看到和使用的是同一批资产,项目之间互相隔离。归档项目会撤销它的全部 key,资产保留。", + help: "项目拥有一组资产:Agent、Session、Skill、环境模板、文件和 Vault。同一个项目的所有 API key 看到和使用的是同一批资产,项目之间互相隔离。归档项目会撤销它的全部 key,资产保留。应用用这些项目 API Key 调用 Agents API(/v1);登录控制台用的 Core Key 与它们无关,也不在这里列出。", create: "创建项目", refresh: "刷新项目", loading: "正在加载项目…", @@ -196,12 +196,12 @@ export const keys: TranslationShape = { }, firstRun: { title: "创建第一个项目", - lead: "项目存放 Agent、Session 和文件,调用方用 API key 访问它。", + lead: "项目存放 Agent、Session 和文件,应用用项目 API Key 访问它。", projectName: "项目名称", submit: "创建项目和 key", submitting: "正在创建…", projectCreated: "项目 {{project}} 已创建,签发它的 key 后继续。", - readyTitle: "API key 已就绪", + readyTitle: "项目 API Key 已就绪", keyCaption: "{{key}} · {{project}}", tryIt: "试一下", tryItHelp: "在终端里把 PROJECT_API_KEY 设为上面的项目 API Key 后运行。它会列出项目里的 Agent,新项目还没有 Agent。控制台不会发送这个请求。", diff --git a/apps/web/src/i18n/locales/zh-CN/onboarding.ts b/apps/web/src/i18n/locales/zh-CN/onboarding.ts index bd9a37cae..7d077b141 100644 --- a/apps/web/src/i18n/locales/zh-CN/onboarding.ts +++ b/apps/web/src/i18n/locales/zh-CN/onboarding.ts @@ -1,17 +1,14 @@ export const onboarding = { steps: { label: "设置进度", - account: "管理员", - project: "项目和 key", + project: "项目和 API Key", tour: "认识控制台", }, stage: { - account: { title: "一个 Core,承载所有 Agent。", body: "连接你的机器,运行你的 Agent,在一个控制台里看清每一个 Session。" }, - login: { title: "欢迎回到你的 Core。", body: "你的 Agent、Session 和机器,都还在原处。" }, - project: { title: "项目承载所有工作。", body: "Agent、Session、Skills、文件和 Vault 都归属某个项目;应用用项目的 API key 访问它们。" }, + login: { title: "一个 Core,承载所有 Agent。", body: "连接你的机器,运行你的 Agent,在一个控制台里看清每一个 Session。" }, + project: { title: "项目承载所有工作。", body: "Agent、Session、Skills、文件和 Vault 都归属某个项目;应用用项目 API Key 访问它们。" }, orbit: "Core 和它管理的一切:Agent、Session、Skills、Vault、文件、环境模板和机器", }, - preparing: "正在准备你的控制台…", terminal: "在终端里试一下", tour: { eyebrow: "认识控制台 · {{n}} / {{total}}", @@ -43,7 +40,7 @@ export const onboarding = { name: "平台", title: "部署本身。", points: [ - "项目与 key:创建项目,签发只显示一次的 key,撤销或归档。", + "项目与 key:创建项目,签发只显示一次的项目 API Key,撤销或归档。", "节点:一条命令接入你的机器,随时看容量。", "系统:Core 的构建版本和启动配置。", ], diff --git a/apps/web/src/lib/console-auth-strings.ts b/apps/web/src/lib/console-auth-strings.ts index a0555bc50..11ca907d8 100644 --- a/apps/web/src/lib/console-auth-strings.ts +++ b/apps/web/src/lib/console-auth-strings.ts @@ -3,27 +3,17 @@ export const consoleAuthChinese = { "Copied": "已复制", "A place for your Agents to work.": "让你的 Agent 在这里工作。", "Connect your machines. Create Agents. Watch work happen.": "连接自己的机器,创建 Agent,让工作在这里发生。", - "Create your administrator account": "创建管理员账户", - "You manage this cloud.": "这片云,由你管理。", - "Use your administrator account to continue.": "使用管理员账户继续。", - "Welcome back": "欢迎回来", - "Administrator username": "管理员用户名", - "Password": "密码", - "Confirm password": "确认密码", - "12–72 bytes. Keep this password somewhere safe.": "12–72 字节,请妥善保管这个密码。", - "Letters, numbers, dots, underscores and hyphens.": "支持英文字母、数字、点、下划线和短横线。", - "Create administrator account": "创建管理员账户", + "Sign in to Parsar Core": "登录 Parsar Core", + "Core key": "Core Key", + "This deployment's Core key. The installer saved it in a private file: {{file}} in the installation directory, by default {{defaultPath}}. It is an administration credential: it cannot call the /v1 Agents API, and the console never keeps it in your browser.": + "这个部署的 Core Key,由安装器保存在安装目录下的私有文件 {{file}} 中,默认是 {{defaultPath}}。它是管理凭据:不能调用 /v1 Agents API,控制台也不会把它保存在你的浏览器里。", "Sign in": "登录", "Signing in…": "正在登录…", - "Creating account…": "正在创建账户…", - "Passwords do not match.": "两次输入的密码不一致。", - "Use a password between 12 and 72 bytes.": "请使用 12–72 字节的密码。", - "Check your sign-in details and try again.": "请检查登录信息后重试。", - "Check the account details and try again.": "请检查账户信息后重试。", - "An administrator already exists. Sign in to continue.": "管理员账户已创建,请登录继续。", + "This Core key is not correct. Check it and try again.": "Core Key 不正确,请检查后重试。", + "Too many attempts. Try again in {{seconds}} seconds.": "尝试次数过多,请 {{seconds}} 秒后再试。", "Too many attempts. Wait a moment before trying again.": "尝试次数过多,请稍后再试。", - "Could not confirm the result. Check the account status before trying again.": "暂时无法确认结果,请先检查账户状态。", - "Check account status": "检查账户状态", + "The console cannot check the key right now. Try again later.": "控制台暂时无法校验 Core Key,请稍后再试。", + "Could not sign in. Try again.": "登录失败,请重试。", "Connecting to your console…": "正在连接控制台…", "Could not connect to your console.": "暂时无法连接控制台。", "Sign out": "退出登录", diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 1459f8a2b..67405a939 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded console account name is an audit label, not Core authorization. +The forwarded audit actor label is not Core authorization. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index 53ee27355..b151ed0ea 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -32,22 +32,24 @@ application's caller-managed `self_hosted` Runtime, including its own E2B setup. | `CORE_CONSOLE_ADDR` | Console listener address | | `CORE_CONSOLE_ORIGIN` | Exact browser-facing origin used for host and origin checks | | `CORE_CONSOLE_UPSTREAM` | Core HTTP(S) origin, without credentials, query or resource path | -| `CORE_CONSOLE_ADMIN_TOKEN_FILE` | Absolute path to a private regular file containing the deployment credential | -| `CORE_CONSOLE_AUTH_MODE=account` | Enables console account login | -| `CORE_CONSOLE_STATE_DIR` | Private account state directory (login sessions are process-local) | +| `CORE_CONSOLE_CORE_KEY_FILE` | Absolute path to the private regular file containing the Core key | | `CORE_CONSOLE_DIST` | Absolute directory containing the built Web assets | -Account mode exposes `GET /console/auth` and `POST /console/auth/setup`, `/login` -and `/logout`. Initial setup registers the console account; subsequent login uses -a same-origin session cookie. Console accounts do not create Core API users, -Projects, roles or application keys. `GET /console/config` provides safe console +The console exposes `GET /console/auth` and `POST /console/auth/login` and +`/logout`. The administrator signs in with the deployment's Core key, which the +installer writes to `admin/core.key` under the installation directory (by default +`~/.parsar/core/admin/core.key`; see [Core key](../getting-started/operations.md#core-key)). +The server compares it in constant time and answers with a same-origin session +cookie held only in its memory; the key is never logged or returned, and the +browser does not store it. A console restart or a Core key rotation requires +signing in again. There are no console accounts, usernames or setup step, and the +Core key cannot call `/v1`. `GET /console/config` provides safe console configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. Preserve host/origin checks and the management route allowlist. Browser authorization, cookies and actor headers are replaced or removed before forwarding to Core. -The service reports the authenticated console account as an audit label; a browser -cannot choose that label. Keep deployment, application, node and provider credentials +A browser cannot choose the audit actor label the service reports. Keep deployment, application, node and provider credentials out of `VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys @@ -70,8 +72,8 @@ work. Ordinary metadata reads cannot recover plaintext. browser-to-console and console-to-Core path. 3. A Project key must work on its public resources and fail on management routes. The deployment credential must fail on `/v1`; `/v1` through the console stays 404. -4. Cross-origin management writes must be rejected. Audit actor labels must reflect - the signed-in console account despite a forged browser header. +4. Cross-origin management writes must be rejected. Audit actor labels must ignore + a forged browser header. 5. Runtime observations and history report execution state separately from startup configuration. Neither a login nor a successful configuration read proves model or sandbox readiness. diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index a8ba98f00..3cd3c222d 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -15,14 +15,14 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | -| Console server | `/console/auth`, `/console/auth/{setup,login,logout}`, `/console/config` | Console account (session cookie) or legacy Basic authentication | Sign-in and sign-out; non-secret capability flags such as `sandbox_admin` | +| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; non-secret capability flags such as `sandbox_admin` | | Administrator API | `/core/v1/admin/**` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, provenance, summaries, Runtime observations | | Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics | | Agents API | `/v1/**` | Project API key | Not used. The first-run screen shows a `curl` example for `/v1/agents` with a `$PROJECT_API_KEY` placeholder; the console never sends it | -Browser requests are same-origin and carry only the console sign-in. The browser -never holds or sends the deployment credential, an API key or an `OpenAI-Beta` -header. Responses are validated: a malformed value is reported as a failure, or +Browser requests are same-origin and carry only the console session. The browser +sends the Core key once, in the sign-in request body, and never stores it; it never +holds or sends an API key or an `OpenAI-Beta` header. Responses are validated: a malformed value is reported as a failure, or marked as unrecognised where noted below, and never replaced by a guessed or zero value. diff --git a/docs/web/roadmap.md b/docs/web/roadmap.md index 14ec2c494..edd516007 100644 --- a/docs/web/roadmap.md +++ b/docs/web/roadmap.md @@ -13,8 +13,10 @@ contract. Public Agents API compatibility work is tracked in the - Console login and same-origin `AdminClient` and sandbox management requests; the browser holds no deployment credential or application key. -- First run: administrator setup, the first project and its key shown once, and a - short tour of the console before it opens. +- Sign-in with the deployment's Core key; the browser keeps only the session + cookie. +- First run: after signing in with no project, the first project and its API key + shown once, and a short tour of the console before it opens. - Projects and keys: create, rename, archive, issue with one-time display, revoke; uncertain writes are reported, never replayed. - Resource inspection and permitted deletion; no execution, resource editors, @@ -38,8 +40,8 @@ production console service. Browser acceptance must also cover denied cross-orig 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 +console service: Core key sign-in, a refused key and repeated attempts, and sign-out, +with no credential in browser storage; first run from sign-in to the console; Project creation, one-time key display, revocation and archive; an unconfirmed key issue that is reported and never replayed; a refused deletion that keeps Core's reason; the monitor pages and a read-only Session conversation; node enrollment and removal. From 32bceeaa9d3ae245001d6bc7a714bab527c6b074 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:22:46 +0000 Subject: [PATCH 2/4] Treat a console config without sandbox_admin as granted; name the console audit actor --- apps/web/e2e/fixture-console.mjs | 2 +- .../web/src/features/sandbox/console-config.test.ts | 12 ++++-------- apps/web/src/features/sandbox/console-config.ts | 13 +++++++------ docs/web/architecture.md | 2 +- docs/web/core-connection.md | 2 +- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 572c7b8f9..bcc6ec0e3 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -113,7 +113,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") { - 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."); } diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index 3aefd63c1..7c0f62ad7 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -4,7 +4,7 @@ import { sandboxConsoleConfig } from "./console-config"; afterEach(() => vi.unstubAllGlobals()); describe("bundled console capabilities", () => { it("uses the existing console login without sending a project or admin bearer", async () => { - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }))); + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64) }))); vi.stubGlobal("fetch", fetch); const controller = new AbortController(); expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); @@ -22,12 +22,8 @@ describe("bundled console capabilities", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Bad gateway", { status: 502 }))); await expect(sandboxConsoleConfig(new AbortController().signal)).rejects.toThrow(); }); - it.each([true, false])("preserves explicit API key management capability %s", async (enabled) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ api_keys: enabled })))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.api_keys).toBe(enabled); - }); - it("keeps an absent or malformed API key capability unknown", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ api_keys: "false" })))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.api_keys).toBeUndefined(); + it("disables sandbox administration only when the console says so", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ sandbox_admin: false })))); + expect((await sandboxConsoleConfig(new AbortController().signal))?.sandbox_admin).toBe(false); }); }); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index 92bf78fb4..73a14b285 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -1,14 +1,16 @@ export interface SandboxConsoleConfig { sandbox_admin: boolean; - api_keys?: boolean; node_installer: boolean; node_installer_sha256: string; } /** - * The console's capability flags. An absent endpoint (404, an older console) - * means no sandbox administration; any other failure is thrown so callers - * report a failed read instead of "not configured". + * The console's capability flags. Signing in with the Core key grants + * administration, so Core reports only the node installer and sandbox + * administration is available unless the console says `sandbox_admin: false`. + * An absent endpoint (404, an older console) means no sandbox administration; + * any other failure is thrown so callers report a failed read instead of + * "not configured". */ export async function sandboxConsoleConfig(signal: AbortSignal): Promise { const response = await fetch("/console/config", { credentials: "include", signal }); @@ -16,8 +18,7 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise; return { - sandbox_admin: config.sandbox_admin === true, - ...(typeof config.api_keys === "boolean" ? { api_keys: config.api_keys } : {}), + sandbox_admin: config.sandbox_admin !== false, node_installer: config.node_installer === true && /^[a-f0-9]{64}$/.test(config.node_installer_sha256 ?? ""), node_installer_sha256: config.node_installer_sha256 ?? "", }; diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 67405a939..0faad230e 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded audit actor label is not Core authorization. +The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly have none); it is display only, not Core authorization. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index b151ed0ea..fc11cf8bd 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -49,7 +49,7 @@ configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. Preserve host/origin checks and the management route allowlist. Browser authorization, cookies and actor headers are replaced or removed before forwarding to Core. -A browser cannot choose the audit actor label the service reports. Keep deployment, application, node and provider credentials +A browser cannot choose the audit actor label the service reports: console operations always show as `console`. Keep deployment, application, node and provider credentials out of `VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys From db26d9f0ab0dec71a31ca375880964cb3c65dc06 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:33:21 +0000 Subject: [PATCH 3/4] Direct Core key calls leave the audit actor empty --- docs/web/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 0faad230e..f605326d5 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly have none); it is display only, not Core authorization. +The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly leave it empty); it is display only, not Core authorization. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not From 70bb210a0b42ad0219a1400136d8785fc50522a6 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:52:01 +0000 Subject: [PATCH 4/4] The right Core key always signs in; the audit actor is caller-declared --- apps/web/e2e/access.spec.ts | 3 +++ apps/web/e2e/fixture-console.mjs | 7 ++++--- docs/web/architecture.md | 2 +- docs/web/core-connection.md | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index cec15d4ff..7c704978b 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -32,6 +32,9 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and await page.getByRole("button", { name: "Sign out" }).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); }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index bcc6ec0e3..1262b8031 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -82,16 +82,17 @@ function authError(response, status, message, headers = {}) { send(response, status, { error: message }, headers); } -/** Sign-in with the Core key: a wrong key is refused, and repeated wrong keys lock sign-in for a while. */ +/** 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"); - const wait = Math.ceil((auth.lockedUntil - Date.now()) / 1000); - if (wait > 0) return authError(response, 429, "Too many authentication attempts", { "retry-after": String(wait) }); 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; diff --git a/docs/web/architecture.md b/docs/web/architecture.md index f605326d5..c113ded25 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly leave it empty); it is display only, not Core authorization. +The audit actor label is declared by the caller and is display only, never Core authorization: the console server declares `console`, and operator scripts calling Core with the Core key directly leave it empty. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index fc11cf8bd..ae79ed356 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -49,7 +49,7 @@ configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. Preserve host/origin checks and the management route allowlist. Browser authorization, cookies and actor headers are replaced or removed before forwarding to Core. -A browser cannot choose the audit actor label the service reports: console operations always show as `console`. Keep deployment, application, node and provider credentials +A browser cannot choose the audit actor label the service reports: the console server declares `console`. The label is caller-declared and display only. Keep deployment, application, node and provider credentials out of `VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys