From 50d92bf967fe48046fcf390056565084afd942e2 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Thu, 24 Sep 2026 04:56:09 +0800 Subject: [PATCH 1/2] Add administrator onboarding and Agent API key management --- CONTRIBUTING.md | 66 +++- README.md | 7 +- apps/web/e2e/first-run-recovery.spec.ts | 45 +++ apps/web/e2e/first-run.spec.ts | 324 ++++++++++++++++++ apps/web/src/App.tsx | 31 +- apps/web/src/features/agents/AgentsView.tsx | 11 + .../web/src/features/api-keys/ApiKeyPanel.tsx | 133 +++++++ apps/web/src/features/api-keys/api-keys.css | 20 ++ .../src/features/api-keys/api-keys.test.ts | 32 ++ apps/web/src/features/api-keys/api-keys.ts | 32 ++ .../src/features/first-run/ConsoleAccess.tsx | 148 ++++++++ .../features/first-run/ConsoleNavigation.tsx | 17 + .../src/features/first-run/FirstRequest.tsx | 107 ++++++ .../src/features/first-run/FirstRunHome.css | 122 +++++++ .../src/features/first-run/FirstRunHome.tsx | 88 +++++ apps/web/src/features/first-run/auth.test.ts | 35 ++ apps/web/src/features/first-run/auth.ts | 35 ++ .../src/features/first-run/console-access.css | 27 ++ .../first-run/example-request.test.ts | 286 ++++++++++++++++ .../src/features/first-run/example-request.ts | 136 ++++++++ apps/web/src/features/first-run/progress.ts | 19 + .../features/first-run/use-example-request.ts | 41 +++ .../src/features/first-run/useIntroduction.ts | 22 ++ .../src/features/sandbox/NodeEnrollment.tsx | 10 +- .../features/sandbox/SandboxManagerView.tsx | 18 +- .../features/sandbox/console-config.test.ts | 8 + .../src/features/sandbox/console-config.ts | 2 + apps/web/src/lib/api-key-strings.ts | 38 ++ apps/web/src/lib/console-auth-strings.ts | 37 ++ apps/web/src/lib/first-run-strings.ts | 75 ++++ apps/web/src/lib/locale-strings.ts | 6 + apps/web/src/main.tsx | 3 +- .../agents-api/sandbox-manager.openapi.yaml | 171 +++++++++ deploy/install/configuration.py | 19 +- deploy/install/install.py | 58 +++- deploy/install/test_install.py | 71 +++- docs/getting-started/install.md | 53 ++- docs/getting-started/operations.md | 7 +- services/agents-api/cmd/server/main.go | 10 + services/agents-api/internal/api/auth.go | 54 ++- services/agents-api/internal/api/errors.go | 2 + services/agents-api/internal/api/handler.go | 2 + .../internal/api/project_api_keys.go | 169 +++++++++ .../internal/api/project_api_keys_test.go | 235 +++++++++++++ .../internal/db/queries/project_api_keys.sql | 21 ++ .../agents-api/internal/db/sqlc/models.go | 15 + .../internal/db/sqlc/project_api_keys.sql.go | 182 ++++++++++ .../internal/store/project_api_keys.go | 160 +++++++++ .../store/project_api_keys_http_test.go | 81 +++++ .../internal/store/project_api_keys_test.go | 196 +++++++++++ .../migrations/000061_project_api_keys.sql | 20 ++ services/core-console/account_store.go | 153 +++++++++ services/core-console/account_store_test.go | 115 +++++++ services/core-console/api_keys.go | 69 ++++ services/core-console/api_keys_test.go | 111 ++++++ services/core-console/auth.go | 256 ++++++++++++++ services/core-console/auth_test.go | 293 ++++++++++++++++ services/core-console/config.go | 30 +- services/core-console/node_installation.go | 3 +- services/core-console/project_proxy_test.go | 114 ++++++ services/core-console/sandbox_admin.go | 4 + services/core-console/sandbox_admin_test.go | 8 +- services/core-console/server.go | 89 +++-- 63 files changed, 4662 insertions(+), 90 deletions(-) create mode 100644 apps/web/e2e/first-run-recovery.spec.ts create mode 100644 apps/web/e2e/first-run.spec.ts create mode 100644 apps/web/src/features/api-keys/ApiKeyPanel.tsx create mode 100644 apps/web/src/features/api-keys/api-keys.css create mode 100644 apps/web/src/features/api-keys/api-keys.test.ts create mode 100644 apps/web/src/features/api-keys/api-keys.ts create mode 100644 apps/web/src/features/first-run/ConsoleAccess.tsx create mode 100644 apps/web/src/features/first-run/ConsoleNavigation.tsx create mode 100644 apps/web/src/features/first-run/FirstRequest.tsx create mode 100644 apps/web/src/features/first-run/FirstRunHome.css create mode 100644 apps/web/src/features/first-run/FirstRunHome.tsx create mode 100644 apps/web/src/features/first-run/auth.test.ts create mode 100644 apps/web/src/features/first-run/auth.ts create mode 100644 apps/web/src/features/first-run/console-access.css create mode 100644 apps/web/src/features/first-run/example-request.test.ts create mode 100644 apps/web/src/features/first-run/example-request.ts create mode 100644 apps/web/src/features/first-run/progress.ts create mode 100644 apps/web/src/features/first-run/use-example-request.ts create mode 100644 apps/web/src/features/first-run/useIntroduction.ts create mode 100644 apps/web/src/lib/api-key-strings.ts create mode 100644 apps/web/src/lib/console-auth-strings.ts create mode 100644 apps/web/src/lib/first-run-strings.ts create mode 100644 services/agents-api/internal/api/project_api_keys.go create mode 100644 services/agents-api/internal/api/project_api_keys_test.go create mode 100644 services/agents-api/internal/db/queries/project_api_keys.sql create mode 100644 services/agents-api/internal/db/sqlc/project_api_keys.sql.go create mode 100644 services/agents-api/internal/store/project_api_keys.go create mode 100644 services/agents-api/internal/store/project_api_keys_http_test.go create mode 100644 services/agents-api/internal/store/project_api_keys_test.go create mode 100644 services/agents-api/migrations/000061_project_api_keys.sql create mode 100644 services/core-console/account_store.go create mode 100644 services/core-console/account_store_test.go create mode 100644 services/core-console/api_keys.go create mode 100644 services/core-console/api_keys_test.go create mode 100644 services/core-console/auth.go create mode 100644 services/core-console/auth_test.go create mode 100644 services/core-console/project_proxy_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7b0513f1..f96d6aaea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1666,8 +1666,9 @@ credential encryption key. Provider identity/backend namespace and native histor must not change on a repeated install. `services/core-console` serves the production Web build and forwards public `/v1` -requests to one configured Core using its project bearer, after console Basic -authentication. The paired console uses the same login for allowlisted sandbox +requests to one configured Core using its project bearer after administrator +authentication. New installations use a console-local single administrator account; +existing installations without explicit account mode retain Basic authentication. The paired console uses the same login for allowlisted sandbox management routes and supplies its private server-side administrator token from `CORE_CONSOLE_SANDBOX_ADMIN_TOKEN_FILE`. The browser receives only capability flags through `/console/config`, never the deployment bearer. Project API keys @@ -1701,7 +1702,66 @@ verification, and passes the enrollment credential only to the installer process Both proxy paths retain fixed-origin, cross-site, safe-path, redirect and Upgrade restrictions through the standard Go reverse proxy with streaming/cancellation. The console implements no product identity, resource semantics, Runtime discovery -or execution loop. +or execution loop. Its local administrator account grants the complete console +surface; do not introduce Web roles, invitations or per-project Web identities. +Agent API caller keys remain independent of the administrator password and cookie. +Explicit, unambiguous caller Bearer requests to public `/v1` routes pass through +unchanged to Core, without borrowing the console's caller or administrator key. +The same origin, path, method and transport restrictions still apply. + +Account mode is explicit (`CORE_CONSOLE_AUTH_MODE=account`) and requires a private +setup-key file and a private writable state directory. Only the installation's +one-time setup credential can claim the administrator account. The atomic durable +account record stores a password hash; corruption or a missing required credential +must never reopen registration. Account creation is race-safe. Cookie sessions are +bounded, HttpOnly, SameSite Strict and Secure for HTTPS origins; a restart requires +sign-in again, without deleting the account. Unauthenticated access is limited to +the static login UI, finite console authentication routes and the existing +independently authenticated node/project transports. Authentication requests use +same-origin JSON POSTs with bounded bodies and bounded password-hash work. + +Console-managed Agent API keys live in Core PostgreSQL, separate from console +login state and model credentials. Only deployment administrator authentication +can create, list or revoke them through the Core management extension. The console +bridge derives the static parent binding digest from its private caller token; +never accept a browser-supplied parent, tenant or subject. A static parent digest +is a selector, not authentication. Freeze the complete configured principal with +each derived key, and reject it whenever the current static binding is missing or +changed. Never derive keys from another dynamic key. The issuer returns a random +secret once and stores only its digest and safe metadata. Reads never return key +material. Check revocation on each dynamic-key request without an auth cache; +database failures fail closed. Static configured-key authentication remains +independent of this lookup. Keep key management outside public `/v1` resources. + +A caller-supplied creation UUID identifies a single key issuance. A repeated UUID +returns conflict without replaying or rotating a secret. After an uncertain create, +read the safe list and explicitly revoke an inaccessible key before replacing it; +any explicit retry uses the same UUID. The first-run UI reminds the operator to +save the key and use it for subsequent Agent API calls. Never persist a displayed +key in browser storage or carry it into the request code or URL. The console has +one administrator role and no project/role editor. +The `/console/config` `api_keys` capability controls whether key management is +available. Paired consoles require an active saved key before continuing from the +access step. Web-only consoles with `api_keys: false` instead explain how to use +an existing Core key and allow the introduction to continue without key-management +requests. A failed or malformed capability read must not imply either capability. + +First-run Home is a skippable/replayable console introduction after account setup. +It does not change public Core resource semantics or block ordinary administration. +Keep new onboarding state and components outside the oversized `App.tsx`. Persist +only non-secret presentation progress; password, setup key and model provider key +must not enter browser storage or generated code samples. Creating a saved Agent +is an explicit write through the existing API. Reconcile uncertain results before +another write, and associate external examples with their exact metadata marker, +not arbitrary new resources or name matches. The request workbench generates code +from its real form fields; local execution obtains caller and model keys separately. +Generated examples reject redirects so credentials stay at the selected API origin. +A registered host supplies sandbox resources only for hosted Sessions; self-hosted +execution remains application-managed. Reuse the existing enrollment and topology +contracts. Show actual confirmed resources, no simulated work or Agent-to-node +ownership. Use nonlinear motion for transitions and success emphasis, preserve +keyboard focus, and honor reduced motion. Compatibility scope and caveats belong +in documentation, not in the introduction. The console has neither KVM nor Docker authority; its static root contains no secrets. Installation exposes only loopback API/console ports. Remote exposure requires an operator-configured HTTPS/access boundary. Web-only mode can connect diff --git a/README.md b/README.md index 225f7ca22..4763e5ce5 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,11 @@ existing Sessions retain their node across disconnects and resume. bundles are not published yet; see the [installation guide](docs/getting-started/install.md) for building a bundle and the host/network prerequisites. 2. **Sign in to Web.** Open the console address printed by the installer. Use - `admin` and the password in `~/.parsar/core/config/console.password`. - The paired console needs no additional API key setup. + the one-time key in `~/.parsar/core/config/console.setup.key` to register your + administrator account, then keep your chosen username and password safe. + Existing installations retain their `admin` / `console.password` login. + The console connects to Core automatically. During first-run, create and save + an Agent API key for requests from your own machine or application. 3. **Add a node.** Open **Hosted Sandbox Manager**, choose Docker or microsandbox, and initialize the deployment. The paired console address is used by default; advanced network settings allow a different reachable HTTPS origin. Select diff --git a/apps/web/e2e/first-run-recovery.spec.ts b/apps/web/e2e/first-run-recovery.spec.ts new file mode 100644 index 000000000..3b10e7099 --- /dev/null +++ b/apps/web/e2e/first-run-recovery.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; + +test("allows editing a copied request without sending another write or changing its marker", async ({ page, request }) => { + expect((await request.post(`${fixtureBaseUrl}/__fixture/reset`)).ok()).toBe(true); + await page.route("**/console/auth", (route) => route.fulfill({ json: { mode: "authenticated", username: "recovery-admin" } })); + await page.addInitScript(() => { + localStorage.setItem("agents-core-web.locale", "en"); + localStorage.setItem(`parsar.introduction.v1:${location.origin}:recovery-admin`, JSON.stringify({ step: 2, dismissed: false })); + }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.goto("/"); + await page.getByLabel("Agent name", { exact: true }).fill("Original external request"); + await page.getByLabel("Provider base URL", { exact: true }).fill("https://models.example/v1"); + await page.getByLabel("Core API base URL", { exact: true }).fill(`${fixtureBaseUrl}/v1`); + const firstCode = await page.locator(".first-request-code pre").innerText(); + const marker = firstCode.match(/"core_home_example": "([^"]+)"/)?.[1]; + expect(marker).toBeTruthy(); + await page.getByRole("button", { name: "Copy request", exact: true }).click(); + await expect(page.getByText("Waiting for your request…", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Agent name", { exact: true })).toBeEnabled(); + await page.getByLabel("Agent name", { exact: true }).fill("Corrected external request"); + await page.getByLabel("Model ID", { exact: true }).fill("fixture/corrected-model"); + await page.getByLabel(/^Model API key/).fill("fixture-memory-only-key"); + await expect(page.getByRole("button", { name: "Run here", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Copy request", exact: true }).click(); + const correctedCode = await page.evaluate(() => navigator.clipboard.readText()); + expect(correctedCode).toContain('"name": "Corrected external request"'); + expect(correctedCode).toContain('"model": "fixture/corrected-model"'); + expect(correctedCode.match(/"core_home_example": "([^"]+)"/)?.[1]).toBe(marker); + expect(correctedCode).not.toContain("fixture-memory-only-key"); + const before = await (await request.get(`${fixtureBaseUrl}/__fixture/requests`)).json() as Array<{ method: string; path: string }>; + expect(before.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents")).toHaveLength(0); + const response = await request.post(`${fixtureBaseUrl}/v1/agents`, { + headers: { "OpenAI-Beta": "agents=v1" }, + data: { name: "Corrected external request", model: "fixture/corrected-model", metadata: { core_home_example: marker } }, + }); + expect(response.status()).toBe(201); + const agent = await response.json() as { id: string }; + await page.getByRole("button", { name: "Check result", exact: true }).click(); + await expect(page.locator(".first-agent-card")).toContainText(agent.id); + await expect(page.locator(".first-agent-card")).toContainText("Corrected external request"); + await expect(page.getByLabel("Agent name", { exact: true })).toBeDisabled(); +}); diff --git a/apps/web/e2e/first-run.spec.ts b/apps/web/e2e/first-run.spec.ts new file mode 100644 index 000000000..0ee3238a0 --- /dev/null +++ b/apps/web/e2e/first-run.spec.ts @@ -0,0 +1,324 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const username = "console-admin"; +const password = "fixture-password-1234"; +type AuthStatus = { mode: "setup" | "login" } | { mode: "authenticated"; username: string }; + +async function fixtureRequests(request: APIRequestContext): Promise }>> { + const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); + expect(response.ok()).toBe(true); + return response.json(); +} + +async function mockAccount(page: Page, initial: AuthStatus) { + let status = initial; + const writes: Array<{ action: string; body: Record }> = []; + await page.route(/\/console\/auth(?:\/(?:setup|login|logout))?$/, async (route) => { + const request = route.request(); + if (request.method() === "POST") { + const action = new URL(request.url()).pathname.split("/").at(-1)!; + writes.push({ action, body: request.postDataJSON() as Record }); + status = action === "logout" ? { mode: "login" } : { mode: "authenticated", username }; + } + await route.fulfill({ json: status }); + }); + return writes; +} + +async function fillAccount(page: Page) { + await page.getByLabel(/^Administrator username/).fill(username); + await page.getByLabel(/^Password/).fill(password); +} + +test.beforeEach(async ({ page, request }) => { + expect((await request.post(`${fixtureBaseUrl}/__fixture/reset`)).ok()).toBe(true); + await page.addInitScript(() => localStorage.setItem("agents-core-web.locale", "en")); + await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: true, sandbox_admin: false, node_installer: false } })); + await page.route("**/console/api-keys", (route) => route.fulfill({ json: { data: [{ + id: "fb533e99-524f-4e44-94bc-8f6e571646a7", name: "Existing terminal key", prefix: "pc_fixture", + created_at: "2026-09-24T00:00:00Z", revoked_at: null, + }] } })); +}); + +test("validates administrator setup before sending credentials and keeps only nonsecret progress", async ({ page }) => { + const writes = await mockAccount(page, { mode: "setup" }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Create your administrator account" })).toBeVisible(); + await page.getByLabel(/^Setup key/).fill("fixture-setup-secret"); + await fillAccount(page); + await page.getByLabel("Confirm password", { exact: true }).fill("different-password"); + await page.getByRole("button", { name: "Create administrator account", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveText("Passwords do not match."); + expect(writes).toHaveLength(0); + await page.getByLabel(/^Password/).fill("short"); + await page.getByLabel("Confirm password", { exact: true }).fill("short"); + await page.getByRole("button", { name: "Create administrator account", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveText("Use a password between 12 and 72 bytes."); + expect(writes).toHaveLength(0); + await page.getByLabel(/^Password/).fill(password); + await page.getByLabel("Confirm password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Create administrator account", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Keep your sign-in details." })).toBeVisible(); + expect(writes).toEqual([{ action: "setup", body: { username, password, setup_key: "fixture-setup-secret" } }]); + await page.getByRole("button", { name: "Copy sign-in details", exact: true }).click(); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain(new URL(page.url()).origin); + expect(copied).toContain(username); + expect(copied).not.toContain(password); + expect(copied).not.toContain("fixture-setup-secret"); + const storage = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })); + expect(storage).not.toContain(password); + expect(storage).not.toContain("fixture-setup-secret"); +}); + +test("remembers the introduction step and dismissal across reloads and sign-in, and allows replay", async ({ page }) => { + const writes = await mockAccount(page, { mode: "authenticated", username }); + await page.goto("/"); + await page.getByRole("button", { name: "I've saved it. Continue", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Connect your own machine." })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "Connect your own machine." })).toBeVisible(); + await page.getByRole("button", { name: "Skip for now", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Make your first API request." })).toBeVisible(); + await page.getByRole("button", { name: "Skip introduction", exact: true }).first().click(); + await expect(page.locator(".first-run-home")).toHaveCount(0); + await page.reload(); + await expect(page.getByRole("button", { name: "Sign out", exact: true })).toBeVisible(); + await expect(page.locator(".first-run-home")).toHaveCount(0); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Welcome back", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toHaveCount(0); + await fillAccount(page); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page.getByRole("button", { name: "Sign out", exact: true })).toBeVisible(); + await expect(page.locator(".first-run-home")).toHaveCount(0); + await page.getByRole("button", { name: "Getting started", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Keep your sign-in details." })).toBeVisible(); + expect(writes.map(({ action }) => action)).toEqual(["logout", "login"]); +}); + +test("keeps private Core requests unmounted on an authentication network failure", async ({ page, request }) => { + await page.route("**/console/auth", (route) => route.abort("failed")); + await page.goto("/"); + await expect(page.getByText("Could not connect to your console.", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toHaveCount(0); + expect(await fixtureRequests(request)).toEqual([]); + await page.unroute("**/console/auth"); + await mockAccount(page, { mode: "login" }); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Welcome back", exact: true })).toBeVisible(); + expect(await fixtureRequests(request)).toEqual([]); +}); + +test("does not replay an uncertain registration and reconciles the account before sign-in", async ({ page }) => { + let status: AuthStatus = { mode: "setup" }; + let setupWrites = 0; + await page.route("**/console/auth", (route) => route.fulfill({ json: status })); + await page.route("**/console/auth/setup", (route) => { + setupWrites++; + status = { mode: "login" }; + return route.abort("failed"); + }); + await page.goto("/"); + await page.getByLabel(/^Setup key/).fill("fixture-setup-secret"); + await fillAccount(page); + await page.getByLabel("Confirm password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Create administrator account", exact: true }).click(); + await expect(page.getByRole("alert")).toHaveText("Could not confirm the result. Check the account status before trying again."); + await expect(page.getByLabel(/^Password/)).toBeDisabled(); + await expect(page.getByRole("button", { name: "Create administrator account", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Check account status", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Welcome back", exact: true })).toBeVisible(); + expect(setupWrites).toBe(1); +}); + +test("supports Chinese setup and reduced-motion introduction", async ({ page }) => { + await mockAccount(page, { mode: "setup" }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.setViewportSize({ width: 1080, height: 900 }); + await page.goto("/"); + await page.getByRole("combobox", { name: "Console language" }).selectOption("zh"); + await expect(page.getByRole("heading", { name: "创建管理员账户" })).toBeVisible(); + await page.getByLabel(/^初始化密钥/).fill("fixture-setup-secret"); + await page.getByLabel(/^管理员用户名/).fill(username); + await page.getByLabel(/^密码/).fill(password); + await page.getByLabel("确认密码", { exact: true }).fill(password); + await page.getByRole("button", { name: "创建管理员账户", exact: true }).click(); + await expect(page.getByRole("heading", { name: "保存你的登录信息。" })).toBeVisible(); + await expect(page.locator(".first-run-home")).toHaveAttribute("lang", "zh"); + const layout = await page.locator(".first-run-stage-content").evaluate((element) => ({ + animation: getComputedStyle(element).animationName, + width: document.documentElement.scrollWidth, + viewport: innerWidth, + })); + expect(layout.animation).toBe("none"); + expect(layout.width).toBeLessThanOrEqual(layout.viewport); + await page.getByRole("button", { name: /第一个 Agent/ }).click(); + await expect(page.getByRole("heading", { name: "发出第一个 API 请求。" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); + +test("runs the copied local request against Core and reveals only its matching Agent", async ({ page, request }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto("/"); + await page.getByRole("button", { name: /Your first Agent/ }).click(); + await page.getByLabel("Agent name", { exact: true }).fill("Ada's first Agent 云"); + await page.getByLabel("Model ID", { exact: true }).fill("fixture/first-run-model"); + await page.getByRole("combobox", { name: "Harness", exact: true }).selectOption("codex"); + await page.getByRole("textbox", { name: "Instructions", exact: true }).fill("Be concise.\nPreserve unicode: 云."); + await page.getByLabel("Set a model provider for this Agent", { exact: true }).check(); + await page.getByRole("combobox", { name: "Provider protocol", exact: true }).selectOption("responses"); + await page.getByLabel("Provider base URL", { exact: true }).fill("https://models.example/v1"); + await page.getByLabel(/^Model API key/).fill("fixture-typed-model-secret"); + await page.getByLabel("Core API base URL", { exact: true }).fill(`${fixtureBaseUrl}/v1`); + const code = await page.locator(".first-request-code pre").innerText(); + expect(code).toContain("CORE_API_KEY"); + expect(code).toContain("MODEL_API_KEY"); + expect(code).not.toContain("fixture-typed-model-secret"); + const [form, editor] = await Promise.all([ + page.locator(".first-request-form").boundingBox(), page.locator(".first-request-editor").boundingBox(), + ]); + expect(form).not.toBeNull(); + expect(editor).not.toBeNull(); + expect(editor!.x).toBeGreaterThanOrEqual(form!.x + form!.width); + await page.getByRole("button", { name: "Copy request", exact: true }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code); + await expect(page.getByLabel(/^Model API key/)).toHaveValue(""); + const unrelated = await request.post(`${fixtureBaseUrl}/v1/agents`, { + headers: { "OpenAI-Beta": "agents=v1" }, + data: { name: "Ada's first Agent 云", model: "fixture/first-run-model", metadata: { core_home_example: "another-request" } }, + }); + expect(unrelated.status()).toBe(201); + await page.getByRole("button", { name: "Check result", exact: true }).click(); + await expect(page.locator(".first-agent-card")).toHaveCount(0); + // Execute exactly the generated Python body, with test-only keys supplied outside the code. + const script = code.split("\n").slice(1, -1).join("\n"); + const outcome = await promisify(execFile)("python3", ["-c", script], { + env: { ...process.env, CORE_API_KEY: "fixture-external-core-key", MODEL_API_KEY: "fixture-external-model-key" }, + timeout: 10_000, + }); + expect(outcome.stdout).toMatch(/^Agent created: agent_created_/); + const createdId = outcome.stdout.trim().split(" ").at(-1)!; + await page.getByRole("button", { name: "Check result", exact: true }).click(); + await expect(page.locator(".first-agent-card")).toContainText(createdId); + await expect(page.locator(".first-agent-card")).toContainText("Ada's first Agent 云"); + const writes = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(writes).toHaveLength(2); + expect(writes[1]?.body).toMatchObject({ + name: "Ada's first Agent 云", model: "fixture/first-run-model", instructions: "Be concise.\nPreserve unicode: 云.", + x_agents_core: { harness: "codex", model_provider: { protocol: "responses", base_url: "https://models.example/v1", api_key: "fixture-external-model-key" } }, + }); + const storage = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })); + expect(storage).not.toContain("fixture-typed-model-secret"); + expect(storage).not.toContain("fixture-external-model-key"); + await page.reload(); + await expect(page.locator(".first-agent-card")).toContainText(createdId); + expect((await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents")).toHaveLength(2); + await page.getByRole("button", { name: "Open Agent", exact: true }).click(); + await expect(page.locator(".first-run-home")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Ada's first Agent 云", exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Saved definition", exact: true })).toContainText(createdId); +}); + +test("creates one Agent through Run here and clears its transient model key", async ({ page, request }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.goto("/"); + await page.getByRole("button", { name: /Your first Agent/ }).click(); + await page.getByLabel("Model ID", { exact: true }).fill("fixture/local-model"); + await page.getByLabel("Set a model provider for this Agent", { exact: true }).check(); + await page.getByLabel("Provider base URL", { exact: true }).fill("https://models.example/v1"); + await expect(page.getByRole("button", { name: "Run here", exact: true })).toBeDisabled(); + await page.getByLabel(/^Model API key/).fill("fixture-local-model-key"); + await page.getByRole("button", { name: "Run here", exact: true }).click(); + await expect(page.locator(".first-agent-card")).toBeVisible(); + await expect(page.getByLabel(/^Model API key/)).toHaveValue(""); + await expect(page.locator(".first-request-code")).not.toContainText("fixture-local-model-key"); + await expect(page.getByRole("button", { name: "Run here", exact: true })).toBeDisabled(); + const writes = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toMatchObject({ model: "fixture/local-model", x_agents_core: { model_provider: { api_key: "fixture-local-model-key" } } }); + expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain("fixture-local-model-key"); +}); + +test("requires acknowledgement of a new API key and never recovers its secret from the list", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + const secret = "pc_fixture-one-time-key-not-persisted-123456789"; + let metadata: { id: string; name: string; prefix: string; created_at: string; revoked_at: string | null } | null = null; + let creates = 0; + let revokes = 0; + await page.route(/\/console\/api-keys(?:\/[^/]+)?$/, async (route) => { + const method = route.request().method(); + if (method === "POST") { + creates++; + const body = route.request().postDataJSON() as { id: string; name: string }; + metadata = { ...body, prefix: "pc_fixture", created_at: "2026-09-24T00:00:00Z", revoked_at: null }; + return route.fulfill({ json: { ...metadata, key: secret } }); + } + if (method === "DELETE") { + revokes++; + metadata = { ...metadata!, revoked_at: "2026-09-24T00:01:00Z" }; + return route.fulfill({ json: { id: metadata.id, deleted: true } }); + } + return route.fulfill({ json: { data: metadata ? [metadata] : [] } }); + }); + await page.goto("/"); + const next = page.getByRole("button", { name: "I've saved it. Continue", exact: true }); + await expect(page.getByText("No API keys yet. Create one to get started.", { exact: true })).toBeVisible(); + await expect(next).toBeDisabled(); + await expect(page.getByRole("button", { name: /Your first Agent/ })).toBeDisabled(); + await page.getByLabel("Key name", { exact: true }).fill("Local terminal"); + await page.getByRole("button", { name: "Create API key", exact: true }).click(); + await expect(page.getByLabel("Your new API key", { exact: true })).toHaveValue(secret); + await expect(next).toBeDisabled(); + await page.getByRole("button", { name: "Copy key", exact: true }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(secret); + expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain(secret); + await expect(next).toBeDisabled(); + await page.getByRole("button", { name: "I've saved this key", exact: true }).click(); + await expect(page.getByLabel("Your new API key", { exact: true })).toHaveCount(0); + await expect(next).toBeEnabled(); + await page.reload(); + await expect(next).toBeEnabled(); + await expect(page.getByLabel("Your new API key", { exact: true })).toHaveCount(0); + await expect(page.locator(".api-key-list")).toContainText("Local terminal"); + await expect(page.locator(".api-key-panel")).not.toContainText(secret); + await page.getByRole("button", { name: "Revoke", exact: true }).click(); + expect(revokes).toBe(0); + await page.getByRole("button", { name: "Confirm revocation", exact: true }).click(); + await expect(page.locator(".api-key-list")).toContainText("Revoked"); + await expect(next).toBeDisabled(); + expect(creates).toBe(1); + expect(revokes).toBe(1); +}); + +test("lets a web-only console use an existing project connection without key management", async ({ page, request }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: false, sandbox_admin: false, node_installer: false } })); + let keyRequests = 0; + await page.route("**/console/api-keys{,/**}", (route) => { + keyRequests++; + return route.fulfill({ status: 503, json: { error: { message: "Key management is not paired." } } }); + }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Use an existing Agent API key.", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Create API key", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "I've saved it. Continue", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Connect your own machine.", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Skip for now", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Make your first API request.", exact: true })).toBeVisible(); + await page.getByLabel("Model ID", { exact: true }).fill("fixture/web-only-model"); + await page.getByLabel("Set a model provider for this Agent", { exact: true }).uncheck(); + await page.getByRole("button", { name: "Run here", exact: true }).click(); + await expect(page.locator(".first-agent-card")).toBeVisible(); + const writes = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toMatchObject({ model: "fixture/web-only-model" }); + expect(keyRequests).toBe(0); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 42248d6e9..182ba4c8a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,6 +16,13 @@ import type { UpdateAgentInput, } from "@agents-core-web/agents-client"; +import { ConsoleNavigation } from "./features/first-run/ConsoleNavigation"; +import { isLocalProxyBaseUrl } from "./lib/connection"; +import { ApiKeyPanel } from "./features/api-keys/ApiKeyPanel"; +import { FirstRunHome } from "./features/first-run/FirstRunHome"; +import { ConsoleAccountMenu } from "./features/first-run/ConsoleAccess"; +import { useIntroduction } from "./features/first-run/useIntroduction"; +import { useLocale } from "./lib/LocaleProvider"; import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; import { SandboxProvider } from "./features/sandbox/SandboxContext"; import { SystemNavigation } from "./components/SystemNavigation"; @@ -143,13 +150,13 @@ import { waitForStreamReconnect, } from "./lib/stream-reconnect"; -type View = ProductView | "system" | "sandbox"; +type View = ProductView | "system" | "sandbox" | "api-keys"; function viewFromLocation(): View { if (typeof window === "undefined") return "dashboard"; const candidate = window.location.hash.slice(1); if (candidate === "templates" && __AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return "templates"; - return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" || candidate === "sandbox" + return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" || candidate === "sandbox" || candidate === "api-keys" ? candidate : "dashboard"; } @@ -251,6 +258,10 @@ export function App() { const [view, setView] = useState(viewFromLocation); const [connection, setConnection] = useState(() => loadConnection()); const [connectionOpen, setConnectionOpen] = useState(false); + const introduction = useIntroduction(connection.baseUrl); + const { t } = useLocale(); + const showIntroduction = introduction.available && introduction.visible && view === "dashboard"; + const [introductionAgentId, setIntroductionAgentId] = useState(); useEffect(() => { const hash = view === "dashboard" ? "" : `#${view}`; @@ -2284,12 +2295,14 @@ export function App() { setView(nextView)} showVaults={vaultSupported === true} showTemplates={__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__} /> + { introduction.replay(); setView("dashboard"); }} onKeys={() => setView("api-keys")} />
@@ -2309,6 +2322,7 @@ export function App() { +
@@ -2332,7 +2346,14 @@ export function App() { onConfigureConnection={() => setConnectionOpen(true)} /> ) : null} -