diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index fe0aec7dd..f24d5aef9 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -504,6 +504,17 @@ request runs. red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. +- **How to call**: wherever a new key is shown, a card under it gives three + copyable samples, each a Margin Gray block with a Hairline and its label and copy + button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the + installation's API base URL) and `OPENAI_API_KEY` (the new key) together, a curl + request and a Python example with the pinned SDK. A copy the clipboard refuses + selects the sample and says so in red underneath. One Graphite line says running + an Agent needs a model provider: in each request, saved on the Agent, or the + deployment default. When the public address is loopback, a note above the + samples says the API is reachable only on the Core machine; without a public + address only a note to set one shows. Before the installation is read, a + skeleton holds the first sample's place. ### Navigation Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; @@ -567,7 +578,9 @@ data on screen, projects that could not be read, and other failed actions are reported in an error toast with the reason. Only when a page or section has nothing to show does an error state take the place of its content; errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline, 12px corners, -12.5px Graphite) state bounded aggregation. Partial-data chips are amber-tinted pills +12.5px Graphite) state bounded aggregation. A standing warning that needs action, +such as the Nodes page naming nodes still bound to an old Core address, is an +amber-tinted line at the top of the page body. Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key shown once, a destructive consequence) stay visible in body text. @@ -617,13 +630,29 @@ each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the -Core address, asking for it only when the console's own address cannot serve. +Core address, read-only: it is config.json's `public_url`, and the console never +asks for it. A loopback address carries an amber line under it: only the Core +machine reaches it. When Core rejects the configuration for it (E2B with a +loopback `public_url`), a red-tinted block under the review keeps Core's message +and adds the config file and apply command as copyable values. A save attempt +clears the E2B key, so the review then says to enter it again, with a link to +that step. Advanced settings, one link away, hold the complete form: resources (not for -E2B), the Core address, the Runtime release and the E2B template. A change keeps the saved size +E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current); another backend starts from its standard size and this console's Runtime, and E2B always needs its key again. Rules sit behind help tips. +### System page +Read-only facts in three sections, each saying where it changes. Installation: the +public address, API base URL, installation ID and source commit as a fact card. +Sandboxes: the shared sandbox configuration, with a "Change on the Nodes page" +text action in the section header. Startup settings: a line naming the config file +and the apply command as copyable chips, with when they were last applied, over a +table of each setting, its value and the services a change restarts. Sensitive +settings show only Configured or Not set; Default and Fixed after install are +neutral pills beside the value. + ### Loading and motion The console has no spinners and no "Loading…" lines. Reads are cached (TanStack Query) and prefetched on navigation hover, so revisits show data at once and diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index ec979796d..f0690a6ff 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -78,9 +78,10 @@ workbench. template build's), a review, and advanced settings with the complete form — then the node list with each node's capacity, host figures and allocations, enrollment, renaming, sandbox limits and removal; Add node - asks for the node's sandbox limits before it issues the one-time command), System (only the sandbox - configuration every project shares: where sandboxes run, each sandbox's size, the - Runtime or E2B template build, microsandbox's idle suspension, the Core address and maintenance). + asks for the node's sandbox limits before it issues the one-time command), System (read-only: the + installation's public address, API base URL, installation ID and source commit; Core's + startup settings from config.json, with the file and the apply command that change them; + and the sandbox configuration every project shares, with a link to Nodes where it changes). - A node whose provider is not ready names the reason (Docker unreachable, no Docker limits, missing Runtime image, no KVM, missing microsandbox components, a host too small) and its fix in the help tip beside its status, wherever that status shows. @@ -113,7 +114,8 @@ workbench. its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. Archiving a project revokes every key and keeps its assets viewable and deletable. Key plaintext is shown once, at issuance, and never - stored by the console. + stored by the console. Beside it the console tells developers to set + `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the key). - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by @@ -122,7 +124,9 @@ workbench. Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **Secrets stay write-only.** Credential tokens, Template environment variables and - setup commands are never returned, to the administrator included. + setup commands are never returned, to the administrator included. An Agent's saved + model provider shows its protocol, base URL, limits and whether a key is configured, + never the key. - **Creators.** Core records the key behind every write. The console shows the creating key of each asset and a project's write history; an asset an administrator copied in an earlier release shows as Admin copy and an asset diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 0c3c9c5b7..d4253641a 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -8,13 +8,16 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; /** * Fixture state options: `fresh` is a new install (no project, Session or Runtime), - * `sandbox` the sandbox deployment, and `nodes: "none"` a deployment no node has joined. + * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and + * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" + * (loopback: only the Core machine reaches the API, and E2B is rejected) or "stale" (public, + * with a node enrolled with an earlier address). */ -export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none" } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale" } /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}`); + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}`); } /** Opens the console already signed in, in English. */ diff --git a/apps/web/e2e/data/agents.mjs b/apps/web/e2e/data/agents.mjs index 14f77edc9..db6b40bff 100644 --- a/apps/web/e2e/data/agents.mjs +++ b/apps/web/e2e/data/agents.mjs @@ -84,8 +84,28 @@ export const agentDefinitions = [ }, ]; -/** Which project owns each definition (index into the demo projects). */ -export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3]; +/** + * Saved Agents with a model provider, as Core reads them: whether a key is saved, + * never the key. One saved only a provider, so it has no harness. No Session uses + * them, so the demo Sessions stay the same. + */ +export const providerAgentDefinitions = [ + { + id: "agent_11c4f2a8", name: "Spec drafter", model: "MiniMax-M2", reasoning: {}, + instructions: "Draft a product spec from the linked discussion: problem, users, scope, open questions.", + tools: [], metadata: { team: "product" }, + x_agents_core: { model_provider: { protocol: "anthropic", base_url: "https://api.minimaxi.com/anthropic", api_key_configured: true, context_window: 204800, max_output_tokens: 65536 } }, + }, + { + id: "agent_12d7e3b9", name: "Research assistant", model: "kimi-k2", reasoning: {}, + instructions: "Answer research questions with cited sources and a one-paragraph summary.", + tools: [], metadata: { team: "research" }, + x_agents_core: { harness: "codex", model_provider: { protocol: "responses", base_url: "https://api.moonshot.cn/v1", api_key_configured: true } }, + }, +]; + +/** Which project owns each definition (index into the demo projects); the provider Agents come last. */ +export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3, 0, 0]; /** Prompt and reply pairs per Agent so Session transcripts read like real work. */ export const agentConversations = { diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index e031b2c17..6f540bf13 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -1,5 +1,5 @@ // Synthetic deployment data for the browser acceptance fixture: Sessions, Turns, Items, nodes. -import { agentConversations, agentDefinitions } from "./agents.mjs"; +import { agentConversations, agentDefinitions, providerAgentDefinitions } from "./agents.mjs"; let seed = 42; const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296); const pick = (list) => list[Math.floor(rand() * list.length)]; @@ -99,5 +99,11 @@ export function buildDemo(now = Math.floor(Date.now() / 1000)) { const sleeping = allocation.compute_phase === "suspended"; return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, cpu: sleeping ? null : { usage_seconds_total: 600 + Math.floor(rand() * 4000), capacity_cores: 2, usage_cores: Number((rand() * 1.6).toFixed(2)), utilization_ratio: null }, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; }); + // Added after everything else is generated, so the seeded data above does not change. + agents.push(...providerAgentDefinitions.map((definition, index) => ({ + ...definition, object: "agent", multi_agent: { enabled: false, max_concurrent_subagents: null }, service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + created_at: now - 86400 * (3 + index), updated_at: now - 1800 * (index + 1), + }))); return { agents, sessions, turns, items, nodes, allocations, observations }; } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 95ab9cc32..fef5dcd09 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -1,6 +1,6 @@ // Browser acceptance fixture: the console service's routes (/console/**) and the -// Core management tree it forwards (/core/v1/**: projects, summary, audit log, -// metrics and /core/v1/sandbox/**), with synthetic, deterministic data and +// Core management tree it forwards (/core/v1/**: installation, projects, summary, +// audit log, metrics and /core/v1/sandbox/**), with synthetic, deterministic data and // in-memory writes. It never serves /v1; any /v1 request, and any // browser-supplied Authorization header, is recorded so a test can assert that // the console stays on its management boundary. @@ -25,8 +25,52 @@ const hex = (c) => c.repeat(64); const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${hex("1")}`, image_manifest_digest: `sha256:${hex("2")}`, microsandbox_ref: `parsar-core-runtime@sha256:${hex("3")}`, runtime_sha256: hex("4"), firmware_sha256: hex("5") }; const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} }; +/** + * config.json's public_url. "public": an HTTPS address, so applications get an API base URL; + * "local": the installer's loopback default, reachable only on the Core machine; + * "stale": public, with a node still enrolled with an earlier address. + */ +const PUBLIC_URL = "https://core.example.com"; +const LOCAL_URL = "http://127.0.0.1:8091"; +/** The address a node enrolled with before public_url last changed. */ +const OLD_URL = "https://core-old.example.com"; +const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); +/** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ +const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; + +/** GET /core/v1/installation: the address, the startup settings from config.json and what is bound to the address. */ +function installation() { + const local = state.installation === "local"; + const setting = (key, value, fallback, restarts, extra = {}) => ({ key, value, default: fallback, changeable: true, sensitive: false, restarts, ...extra }); + const secret = (key, restarts) => ({ key, value: null, default: null, changeable: true, sensitive: true, restarts, configured: true }); + const nodes = state.deployment?.provider && state.deployment.provider !== "e2b" ? state.nodes : []; + return { + // As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url. + object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, + local_only: local, source_commit: release.source_commit, + configuration: { + path: "/opt/parsar/config.json", apply_command: "sudo parsar apply", applied_at: "2026-09-24T09:30:00Z", + settings: [ + setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), + setting("listen_address", "127.0.0.1:8091", "127.0.0.1:8091", ["core"]), + setting("web_listen_address", "127.0.0.1:4173", "127.0.0.1:4173", ["web"]), + setting("data_dir", "/var/lib/parsar", "/var/lib/parsar", [], { changeable: false }), + setting("log_level", "debug", "info", ["core", "web"]), + secret("core_key", ["core", "web"]), + secret("database_url", ["core"]), + ], + }, + // As Core counts them: the deployment's retained and pending hosted sandboxes, and the unrevoked executor credentials. + address_bindings: { + nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, + hosted_sandboxes: (state.deployment?.resources.allocations ?? 0) + (state.deployment?.resources.pending ?? 0), + self_hosted_executors: [...state.executorCredentials.values()].flat().filter((credential) => credential.revoked_at === null).length, + }, + }; +} + function configuredDeployment() { - return { installation_id: "7f3c2a90-fixture", provider: "docker", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; + return { installation_id: INSTALLATION_ID, provider: "docker", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, runtime: release }, specification_digest: "fixture", suspension: null }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; @@ -36,7 +80,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, template_build: templateBuild } }; } -function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo") { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public") { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); @@ -52,9 +96,14 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d violations: [], writes: [], failNext: null, nextId: 1, // Executor credential metadata by environment ID; tokens are never kept. executorCredentials: new Map(), + // How config.json's public_url is set: "public", "local" or "stale". + installation: address, // "none": the deployment is not configured yet, so the Nodes page offers setup. - deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(), + deployment: null, }; + state.deployment = sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(); + // Each node reports the address it enrolled with; in "stale" mode the first one enrolled before public_url changed. + state.nodes.forEach((node, index) => { node.core_url = address === "stale" && index === 0 ? OLD_URL : publicUrl(); }); if (sandbox === "e2b") Object.assign(state, { nodes: [], allocations: [] }); } reset(); @@ -63,11 +112,11 @@ function send(response, status, body, headers = {}) { response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers }); response.end(JSON.stringify(body)); } -function error(response, status, message, code = null) { +function error(response, status, message, code = null, param = null) { // Derive `type` as Core's writeError does (services/agents-api/internal/api/errors.go). const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error" : code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error"; - send(response, status, { error: { message, type, code, param: null } }); + send(response, status, { error: { message, type, code, param } }); } async function body(request) { const chunks = []; @@ -173,6 +222,7 @@ async function adminWrite(request, response, path) { function adminRead(response, path, url) { const a = state.admin; + if (path === "/installation") return send(response, 200, installation()); if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); @@ -235,23 +285,30 @@ async function sandboxRoute(request, response, path) { const data = state.admin.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); } - if (path === "/deployment" && request.method === "POST") { + if (path === "/deployment" && (request.method === "POST" || request.method === "PUT")) { const input = await body(request); - if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); + const initialize = request.method === "POST"; + // As Core, before any state check: the address is config.json's public_url and read-only. + if ("core_url" in input) return error(response, 400, "core_url is derived from the installation public URL (public_url in config.json, AGENTS_API_PUBLIC_URL for Core) and cannot be set here. Remove it.", "invalid_request_error", "core_url"); + if (initialize && state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); + if (!initialize && !state.deployment) return error(response, 409, "The sandbox deployment is not configured.", "sandbox_deployment_conflict"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + // As Core (ErrSandboxPublicURLUnreachable): E2B sandboxes reach Core over the internet, which a loopback public_url cannot serve. + if (e2b && state.installation === "local") return error(response, 409, "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback (public_url in config.json, AGENTS_API_PUBLIC_URL for Core).", "sandbox_configuration_error"); // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; state.deployment = { - ...configuredDeployment(), provider: input.provider, core_url: input.core_url, mode: e2b ? "direct" : "nodes", + ...configuredDeployment(), provider: input.provider, mode: e2b ? "direct" : "nodes", + ...(initialize ? {} : { maintenance: true, generation: state.deployment.generation + 1 }), specification: { resources, ...(input.runtime ? { runtime: input.runtime } : {}) }, ...(e2b ? { e2b: { template: input.e2b?.template ?? "", credential_configured: true, template_build: templateBuild } } : {}), suspension: input.provider === "microsandbox" ? { idle_seconds: 300, retention_seconds: 86400 } : null, }; - return send(response, 201, state.deployment); + return send(response, 200, state.deployment); } if (path === "/deployment") { - return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: `http://127.0.0.1:${port}`, maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); + return send(response, 200, state.deployment ?? { installation_id: INSTALLATION_ID, provider: "", core_url: publicUrl(), maintenance: false, owner_epoch: 3, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }); } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { @@ -334,14 +391,14 @@ async function executorCredentialRoute(request, response, projectId, environment */ function registeredNode(nodeId) { const limits = state.enrollmentLimits ?? { max_active: 1, max_retained: 1 }; - return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; + return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } /** Test controls: reset state, inject one failure, register or change a node, and read what the browser sent. */ 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") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo"); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public"); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index f3e2f3a41..234e73263 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -76,7 +76,7 @@ test("adds a node: host requirements, a countdown, the same command after closin await page.clock.fastForward("01:01"); const problem = add.getByRole("alert"); await expect(problem).toContainText("Not connected yet"); - await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-fixture.service"); + await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service"); // Connected, it reports why Docker isn't ready; once ready, the node is connected. await setNode(request, { id: "node-new", online: true, diagnostic: "docker_limits_unsupported" }); await expect(problem).toContainText("Docker limits unsupported"); @@ -106,7 +106,6 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist await page.getByRole("button", { name: "Docker" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); - await page.getByLabel("Core address").fill("https://core.example.com"); await page.getByRole("button", { name: "Save configuration" }).click(); // The saved specification carries the Runtime read from the console's manifest. @@ -125,7 +124,6 @@ test("saves E2B without opening Add node, as it has no machines", async ({ page, await page.getByLabel("E2B API key").fill("fixture-private-key"); await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); await page.getByRole("button", { name: "Next" }).click(); - await page.getByLabel("Core address").fill("https://core.example.com"); await page.getByRole("button", { name: "Save configuration" }).click(); await expect(page.getByRole("heading", { name: "Sandbox backend", level: 1 })).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); @@ -155,6 +153,8 @@ test("keeps the saved size and Runtime for the same backend, and starts another await save.click(); await expect.poll(() => submitted?.resources).toEqual(current.resources); expect(submitted?.runtime).toEqual(runtime); + // Core's address is config.json's public_url: a change never sends it. + expect(submitted).not.toHaveProperty("core_url"); // Another backend starts from its own size, with disks, and this console's Runtime. await back.click(); diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index 89ec72a72..ffd6986da 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -16,6 +16,12 @@ test("creates a project, shows a new key once, revokes it and archives the proje await page.getByRole("dialog").getByRole("button", { name: "Issue key" }).click(); const issued = page.getByRole("dialog", { name: "Key issued" }); await expect(issued.getByLabel("New key ci")).toHaveValue(/fixture-secret/); + // How to call Core with it: the official SDK's variables, set to the API base URL and this key. + const key = await issued.getByLabel("New key ci").inputValue(); + const call = issued.getByRole("region", { name: "How to call" }); + await expect(call.getByLabel("Shell", { exact: true })).toHaveText(`export OPENAI_BASE_URL=https://core.example.com/v1\nexport OPENAI_API_KEY=${key}`); + await expect(call.getByLabel("curl", { exact: true })).toContainText('curl "$OPENAI_BASE_URL/agents"'); + await expect(call.getByLabel("Python", { exact: true })).toContainText("pip install openai==3.13.0"); await issued.getByRole("button", { name: "I've saved this key" }).click(); await expect(page.getByRole("table", { name: "Keys of Acceptance" })).toContainText("ci"); await expect(page.getByLabel("New key ci")).toHaveCount(0); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts new file mode 100644 index 000000000..7ef359f2d --- /dev/null +++ b/apps/web/e2e/public-url.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, openConsole, writes } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("sets up sandboxes with Core's address read-only, never sending it", async ({ page, request }) => { + const bodies: string[] = []; + page.on("request", (sent) => { if (sent.url().includes("/core/v1/sandbox/deployment")) bodies.push(sent.postData() ?? ""); }); + await openConsole(page, request, "nodes", { sandbox: "none" }); + await page.getByRole("button", { name: "Own machines" }).click(); + await page.getByRole("button", { name: "Docker" }).click(); + await page.getByRole("button", { name: /^Standard/ }).click(); + const review = page.getByRole("definition").filter({ hasText: "https://core.example.com" }); + await expect(review).toContainText("Set by public_url in config.json"); + await expect(page.getByRole("textbox", { name: "Core address" })).toHaveCount(0); + await page.getByRole("button", { name: "Save configuration" }).click(); + // Saved: own machines continue straight to Add node. + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); + expect(bodies.some((entry) => entry.includes("core_url"))).toBe(false); +}); + +test("explains an E2B rejection in the wizard, with the file to edit and the command to apply it", async ({ page, request }) => { + await openConsole(page, request, "nodes", { sandbox: "none", installation: "local" }); + await page.getByRole("button", { name: "E2B cloud" }).click(); + await page.getByLabel("E2B API key").fill("fixture-private-key"); + await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await page.getByRole("button", { name: "Next" }).click(); + const address = page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" }); + await expect(address).toContainText("Only the Core machine can reach this address"); + await page.getByRole("button", { name: "Save configuration" }).click(); + const rejection = page.locator(".wizard-rejection"); + await expect(rejection).toContainText("E2B sandboxes reach Core over the internet."); + await expect(rejection).toContainText("/opt/parsar/config.json"); + await expect(rejection).toContainText("sudo parsar apply"); + // Nothing was saved and nothing is uncertain: no dialog, and the wizard stays on its review. + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); + // The key is gone from the page; saving again asks for it on the E2B step. + expect(await page.content()).not.toContain("fixture-private-key"); + await page.getByRole("button", { name: "Enter the key" }).click(); + await expect(page.getByRole("heading", { name: "Connect E2B" })).toBeVisible(); +}); + +test("lists the startup settings on System with where to change them", async ({ page, request }) => { + await openConsole(page, request, "system"); + const installation = page.getByRole("region", { name: "Installation" }); + await expect(installation).toContainText("https://core.example.com/v1"); + const startup = page.getByRole("region", { name: "Startup settings" }); + await expect(startup).toContainText("Change these in /opt/parsar/config.json, then run sudo parsar apply"); + const settings = startup.getByRole("table", { name: "Startup settings" }); + await expect(settings.getByRole("row", { name: /^log_level/ })).toContainText("debug"); + await expect(settings.getByRole("row", { name: /^listen_address/ })).toContainText("Default"); + await expect(settings.getByRole("row", { name: /^data_dir/ })).toContainText("Fixed after install"); + await expect(settings.getByRole("row", { name: /^core_key/ })).toContainText("Configured"); + await expect(settings.getByRole("row", { name: /^public_url/ })).toContainText("core, web"); +}); + +test("warns on Nodes about a node bound to an old Core address until it is removed", async ({ page, request }) => { + await openConsole(page, request, "nodes", { installation: "stale" }); + const warning = page.getByRole("status").filter({ hasText: "old Core address" }); + await expect(warning).toHaveText("core-01 is still bound to an old Core address. Remove it and add it again."); + await page.getByRole("button", { name: "Remove core-01" }).click(); + await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click(); + await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01"); + await expect(warning).toHaveCount(0); +}); diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index 7a79621bf..0de4af8a1 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -26,3 +26,19 @@ test("goes back to the page a resource was opened from", async ({ page, request await page.getByRole("button", { name: "Back", exact: true }).first().click(); await expect(page.getByRole("heading", { name: "Report builder", level: 1 })).toBeVisible(); }); + +test("reads an Agent saved with a model provider and no harness, without its key", async ({ page, request }) => { + // Production holds Spec drafter, saved with only an x_agents_core.model_provider. + await openConsole(page, request, "agents?project=proj_7f3a91c2"); + const open = page.getByRole("button", { name: "Open Spec drafter" }); + await expect(open).toBeVisible(); + await expect(page.getByText(/Could not load/)).toHaveCount(0); + // With one project shown, the cells are model, harness, … + await expect(page.getByRole("row").filter({ has: open }).getByRole("cell").nth(1)).toHaveText("Core default"); + + await open.click(); + await expect(page.getByRole("heading", { name: "Spec drafter", level: 1 })).toBeVisible(); + const provider = page.getByRole("region", { name: "Model provider" }); + for (const text of ["Anthropic Messages", "https://api.minimaxi.com/anthropic", "Configured", "204,800", "65,536"]) await expect(provider).toContainText(text); + await expect(page.locator("body")).not.toContainText(/api_key|\bsk-/); +}); diff --git a/apps/web/src/components/Skeleton.tsx b/apps/web/src/components/Skeleton.tsx index 3d1f57cb7..2b69e16ef 100644 --- a/apps/web/src/components/Skeleton.tsx +++ b/apps/web/src/components/Skeleton.tsx @@ -3,10 +3,12 @@ const widths = [38, 22, 18, 26, 14, 20, 16]; /** * A first-load placeholder in the shape of the table it stands for. It is shown * only when nothing is cached yet; refreshes keep the real rows on screen. + * Without a label it stays silent, for a page that announces its loading once. */ -export function TableSkeleton({ label, rows = 6, columns = 5 }: { label: string; rows?: number; columns?: number }) { +export function TableSkeleton({ label, rows = 6, columns = 5 }: { label?: string; rows?: number; columns?: number }) { + const announce = label ? { role: "status", "aria-label": label, "aria-busy": true } : { "aria-hidden": true }; return ( -
+
); diff --git a/apps/web/src/features/agents/AgentCatalog.css b/apps/web/src/features/agents/AgentCatalog.css index 84f70b95e..09c84c86a 100644 --- a/apps/web/src/features/agents/AgentCatalog.css +++ b/apps/web/src/features/agents/AgentCatalog.css @@ -12,3 +12,8 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* A provider's base URL takes two grid columns so it stays on one line, like an ID. */ +.resource-facts > .agent-provider-url { + grid-column: span 2; +} diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index dba41b4c0..44be05396 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -1,4 +1,4 @@ -import type { CoreHarnessKind, SavedAgent } from "@agents-core-web/agents-client"; +import type { CoreHarnessKind, ModelProviderView, SavedAgent } from "@agents-core-web/agents-client"; import { ArrowLeft, Bot, ListTree, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -24,6 +24,9 @@ export function harnessLabel(harness: CoreHarnessKind): string { return "Codex"; } +/** Provider protocols by their product names, the same in every language. */ +const protocolNames: Record = { anthropic: "Anthropic Messages", responses: "OpenAI Responses" }; + function coverage(summary: ProjectSummary | undefined, locale?: string): string { if (!summary || summary.coverage.sessions === 0) return MISSING; return formatPercent(summary.coverage.reported / summary.coverage.sessions, locale); @@ -201,6 +204,7 @@ function AgentDetail({ projectId, agentId }: { projectId: string; agentId: strin const name = agent ? agent.name || t("catalog.untitled") : agentId; const tools = agent ? toolRows(agent) : []; const metadata = agent ? Object.entries(agent.metadata ?? {}) : []; + const provider = agent?.x_agents_core?.model_provider; return (
{t("view.noTools")}

}
+ {provider ? ( +
+ {/* The read view has no key, only whether one is saved. */} +
+
{t("view.provider.protocol")}
{protocolNames[provider.protocol]}
+
{t("view.provider.baseUrl")}
{provider.base_url}
+
{t("view.provider.apiKey")}
{provider.api_key_configured ? t("view.provider.keyConfigured") : t("view.provider.keyNotConfigured")}
+ {provider.context_window !== undefined ?
{t("view.provider.contextWindow")}
{formatInteger(provider.context_window, locale)}
: null} + {provider.max_output_tokens !== undefined ?
{t("view.provider.maxOutputTokens")}
{formatInteger(provider.max_output_tokens, locale)}
: null} +
+
+ ) : null} +
{t("view.reasoningEffort")}
{agent.reasoning?.effort ?? t("view.coreDefault")}
diff --git a/apps/web/src/features/api-keys/HowToCall.test.ts b/apps/web/src/features/api-keys/HowToCall.test.ts new file mode 100644 index 000000000..cde476131 --- /dev/null +++ b/apps/web/src/features/api-keys/HowToCall.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import upstream from "../../../../../contracts/agents-api/upstream.json"; +import { callSamples } from "./HowToCall"; + +describe("how-to-call samples", () => { + it("install the SDK release Core's contract is pinned to", () => { + expect(callSamples("https://core.example/v1", "key").python).toContain(`pip install openai==${upstream.sdk_version}\n`); + }); +}); diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx new file mode 100644 index 000000000..0b6f128d4 --- /dev/null +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import { installationQuery } from "../../lib/installation"; +import { useCopy } from "./IssuedKey"; +import "./how-to-call.css"; + +/** The official SDK release Core is pinned to (contracts/agents-api/upstream.json). */ +const SDK_PIN = "openai==3.13.0"; + +/** + * What an application needs to call Core with a Project API key: the official + * SDK's environment variables, a raw request and the SDK itself. Both + * variables are exported together, so a shell that already exports one of + * them never sends the new key to another base URL. + */ +export function callSamples(apiBaseUrl: string, apiKey: string) { + return { + shell: `export OPENAI_BASE_URL=${apiBaseUrl}\nexport OPENAI_API_KEY=${apiKey}`, + curl: [ + 'curl "$OPENAI_BASE_URL/agents" \\', + ' -H "Authorization: Bearer $OPENAI_API_KEY" \\', + ' -H "OpenAI-Beta: agents=v1"', + ].join("\n"), + python: [ + `# pip install ${SDK_PIN}`, + "from openai import OpenAI", + "", + "client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY", + "print(client.beta.agents.list().data)", + ].join("\n"), + }; +} + +/** + * How to call Core with a key that was just issued. The key comes from the + * issuing flow's memory and leaves with it; the console never sends these + * requests. When Core is reachable only on its own machine it says so, and + * without a public address it says to set one instead of guessing. + */ +export function HowToCall({ apiKey }: { apiKey: string }) { + const { t } = useTranslation("keys"); + const { t: tCommon } = useTranslation("common"); + const headingId = useId(); + const installation = useQuery(installationQuery); + const base = installation.data?.api_base_url ?? null; + + let body; + if (installation.data === undefined) { + body = installation.isError && !installation.isFetching + ?

{t("howToCall.failed")}

+ // The first sample's place, as the console's other first reads hold theirs. + :
; + } else if (base === null) { + body =

{t("howToCall.noAddress")}

; + } else { + const samples = callSamples(base, apiKey); + body = <> + {installation.data.local_only ?

{t("howToCall.localOnly")}

: null} + + + +

{t("howToCall.model")}

+ ; + } + return ( +
+

{t("howToCall.title")}

+ {body} +
+ ); +} + +/** A labelled sample with a copy button; when the clipboard refuses, the sample is selected to copy by hand. */ +function CodeSample({ label, value }: { label: string; value: string }) { + const { t } = useTranslation("keys"); + const code = useRef(null); + const { state, copy } = useCopy(value); + useEffect(() => { + if (state !== "failed" || !code.current) return; + const range = document.createRange(); + range.selectNodeContents(code.current); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }, [state]); + const name = state === "copied" ? t("issued.copied") : t("howToCall.copy", { label }); + return ( +
+
+ {label} + +
+
{value}
+ {state === "failed" ?

{t("howToCall.copyFailed")}

: null} +
+ ); +} diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx index fe5cc8fb6..c9dcf003e 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; @@ -10,10 +12,12 @@ const project: Project = { id: "proj_7f3a91c2", name: "Production", status: "act const secret = "pc_live_" + "s".repeat(40); const issued: AdminIssuedKey = { id: "9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f", project_id: "proj_7f3a91c2", name: "bob-laptop", prefix: "pc_live_Zq8", created_at: 300, revoked_at: null, key: secret }; const controls = (flow: KeyFlow): KeyFlowControls => ({ flow, dispatch: () => undefined, submit: async () => undefined }); +// The how-to-call card under a new key reads the installation through the query cache. +const render = (element: ReactElement) => renderToStaticMarkup({element}); describe("one-time key display", () => { it("shows the plaintext read-only, without autofill, with a copy button and a saved confirmation", () => { - const html = renderToStaticMarkup(); + const html = render(); expect(html).toContain(`value="${secret}"`); expect(html).toContain("readOnly"); expect(html).toContain('autoComplete="off"'); @@ -25,7 +29,7 @@ describe("one-time key display", () => { it("keeps a closed dialog's key on the page until it is confirmed saved", () => { const open = renderToStaticMarkup(); expect(open).toBe(""); - const closed = renderToStaticMarkup(); + const closed = render(); expect(closed).toContain(`value="${secret}"`); expect(closed).toContain("is shown only until you confirm it was saved"); }); diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.tsx index 8df6107e7..102e08de3 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { isUsableName, keyNameProblem, normalizeName, type FlowError, type KeyFlow, type NameProblem } from "./key-flows"; +import { HowToCall } from "./HowToCall"; import { PlaintextKey } from "./IssuedKey"; import type { KeyFlowControls } from "./use-key-flow"; @@ -126,6 +127,7 @@ export function KeyFlowDialogs({ controls, taken }: { controls: KeyFlowControls;

{t("issued.onceNotice")}

+
); @@ -143,6 +145,7 @@ export function PendingKeyNotice({ controls }: { controls: KeyFlowControls }) {

{t("issued.pending", { name: flow.issued.name, project: flow.project.name })}

+
diff --git a/apps/web/src/features/api-keys/how-to-call.css b/apps/web/src/features/api-keys/how-to-call.css new file mode 100644 index 000000000..95c780f23 --- /dev/null +++ b/apps/web/src/features/api-keys/how-to-call.css @@ -0,0 +1,79 @@ +/* How to call Core with a new key: labelled, copyable samples and one note. */ + +.how-to-call { + display: grid; + gap: 10px; +} + +.how-to-call > h3 { + margin: 0; + color: var(--fg); + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.how-to-call-sample { + min-width: 0; + overflow: hidden; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 8px; +} + +.how-to-call-sample-head { + display: flex; + min-height: 28px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 4px 0 12px; + color: var(--fg-muted); + font-size: 12px; + border-bottom: 1px solid var(--line); +} + +.how-to-call-sample pre { + margin: 0; + padding: 10px 12px; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; + line-height: 19px; +} + +.how-to-call-sample pre:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.how-to-call .how-to-call-note { + margin: 0; + color: var(--fg-muted); + font-size: 12.5px; + line-height: 18px; +} + +.how-to-call-error { + margin: 0; + padding: 0 12px 10px; + color: var(--danger); + font-size: 12.5px; + line-height: 18px; +} + +/* Before the installation is read: the first sample's place, two lines tall. */ +.how-to-call-skeleton { + display: grid; + gap: 10px; + padding: 14px 12px; +} + +.how-to-call-skeleton .skeleton-bar:first-child { + width: 62%; +} + +.how-to-call-skeleton .skeleton-bar:last-child { + width: 48%; +} diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index 161a4d61d..1b4b13270 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -39,7 +39,7 @@ export function node(id: string, overrides: Partial = {}): SandboxN cpu_count: 8, available_memory_bytes: 1024, available_disk_bytes: 2048, running: 1, snapshots: 0, last_seen_at: "2026-09-24T00:00:00Z", max_active: 4, max_retained: 8, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, - created_at: "2026-09-01T00:00:00Z", + created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", ...overrides, }; } diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 218b6db26..2f4043148 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -67,7 +67,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onMaint

{!changing ? : <> (null); - const initialCoreUrl = window.location.origin; // When a deployment write last had an uncertain outcome; only a read begun after it confirms the state again. const [uncertainSince, setUncertainSince] = useState(null); const setupNeedsRefresh = uncertainSince !== null && !(snapshot && snapshot.readAt > uncertainSince); @@ -85,14 +85,17 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const refresh = useCallback(() => { // Each refresh starts a new read (cancelling one in flight) and resets the forms, as a reload did. setRevision((value) => value + 1); + // The wizard reads the address and config file from the installation. + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); // Settles when the read does; the enrollment dialog waits for it before calling a command expired. return refetch(); - }, [refetch]); + }, [queryClient, refetch]); const toast = useToast(); // A refresh the administrator asks for reports its failure even while an earlier one is still unconfirmed; // the enrollment dialog's own repeated refreshes do not. const refreshByUser = () => { setRevision((value) => value + 1); + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); void refetch().then((result) => { if (result.isError && result.data) toast.show(t("Refresh failed; showing the last loaded state."), { tone: "error", detail: sandboxRequestError(result.error, locale), key: "sandbox-read" }); }); @@ -102,8 +105,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const controller = new AbortController(); lifetime.current = controller; return () => { controller.abort(); lifetime.current = null; }; }, []); - /** Whether Core confirmed the change. */ - async function changeDeployment(operation: (signal: AbortSignal) => Promise): Promise { + /** Whether Core confirmed the change. `fromWizard`: a configuration Core rejects is thrown back to the wizard that sent it. */ + async function changeDeployment(operation: (signal: AbortSignal) => Promise, fromWizard = false): Promise { const controller = lifetime.current; if (!controller || busy || loading || setupNeedsRefresh || !fresh) return false; setBusy(true); setWriteFailure(null); @@ -124,6 +127,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig } } catch (error) { if (!controller.signal.aborted) { + // Core rejected the configuration and saved nothing: the wizard explains why. + if (fromWizard && sandboxConfigurationRejection(error) !== null) throw error; setUncertainSince(performance.now()); setWriteFailure({ error, open: true }); // Nothing re-reads on its own: the operator refreshes to confirm. A later visit reads again. void queryClient.invalidateQueries({ queryKey: sandboxScope, refetchType: "none" }); @@ -133,10 +138,10 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig } /** First setup; own machines continue straight to adding the first node. */ async function initialize(input: InitializeSandboxDeployment) { - if (await changeDeployment((signal) => client.initializeDeployment(input, { signal })) && input.provider !== "e2b") setAdding(true); + if (await changeDeployment((signal) => client.initializeDeployment(input, { signal }), true) && input.provider !== "e2b") setAdding(true); } async function update(input: InitializeSandboxDeployment) { - await changeDeployment((signal) => client.updateDeployment({ ...input, core_url: snapshot!.deployment.core_url, expected_generation: snapshot!.deployment.generation }, { signal })); + await changeDeployment((signal) => client.updateDeployment({ ...input, expected_generation: snapshot!.deployment.generation }, { signal }), true); } async function maintenance(maintenance: boolean) { await changeDeployment((signal) => client.setMaintenance({ maintenance, expected_generation: snapshot!.deployment.generation }, { signal })); @@ -180,6 +185,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig : !snapshot ? (query.isError ? "unavailable" : "wait") : hostedNodes && fresh && !snapshot.deployment.maintenance ? "ready" : "unavailable"; useConsoleIntent("add-node", addNodeReadiness, () => setAdding(true)); + // A node enrolled with another address than Core's current one (config.json's public_url) gets no new sandboxes until it is added again. + const staleNodes = hostedNodes && snapshot ? nodes.filter((node) => node.core_url !== snapshot.deployment.core_url).map((node) => node.name || node.id) : []; const selected = params.id ? nodes.find((node) => node.id === params.id) : undefined; const refreshButton = ; const readFailure = error !== null ? sandboxRequestError(error, locale) : null; @@ -255,9 +262,12 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
{status} - {snapshot && !snapshot.deployment.provider ? : null} + {snapshot && !snapshot.deployment.provider ? : null} {snapshot?.deployment.provider ? <> {snapshot.deployment.maintenance ?

{t("Maintenance is enabled. New sandbox placement is paused.")}

: null} + {staleNodes.length ?

{staleNodes.length === 1 + ? t("{{name}} is still bound to an old Core address. Remove it and add it again.", { name: staleNodes[0] }) + : t("{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.", { count: staleNodes.length, names: new Intl.ListFormat(i18n.resolvedLanguage, { type: "conjunction" }).format(staleNodes) })}

: null} {hostedNodes ?
{nodes.length diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 1322e994a..be3b3f1d1 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -7,9 +7,11 @@ import { useId, useState, type FormEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; +import { CopyableId } from "../../components/list-ui"; import { formatBytes } from "../../lib/format"; +import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; -import { sandboxSetupOrigin } from "./core-origin"; +import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; import "./sandbox-wizard.css"; @@ -66,11 +68,13 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese * release comes from this console's distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its - * defaults and this console's Runtime. The Core address stays as it is and an - * E2B key must be entered again. + * defaults and this console's Runtime. An E2B key must be entered again. + * Core's address is config.json's `public_url`: the review only shows it, and + * a configuration Core rejects for it is explained here, where it was saved. */ -export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switching = false, onSubmit }: { - initialCoreUrl: string; +export function SandboxSetupWizard({ coreUrl, current, disabled, switching = false, onSubmit }: { + /** The deployment's read-only Core address. */ + coreUrl: string; current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string }; disabled: boolean; switching?: boolean; @@ -84,21 +88,21 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; const [resources, setResources] = useState(current?.specification?.resources ?? defaultSandboxResources("docker")); const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); - // An unusable console address (loopback, plain HTTP) is not offered as the Core address. - const [coreUrl, setCoreUrl] = useState(sandboxSetupOrigin(initialCoreUrl) ? initialCoreUrl : ""); const [apiKey, setApiKey] = useState(""); const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); + // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url. + const [rejection, setRejection] = useState(null); + const installation = useQuery(installationQuery); + const address = coreUrl || installation.data?.public_url || null; + const configuration = installation.data?.configuration ?? null; // A release the administrator entered comes first, then the saved one of the same backend, // then the one this console distributes (the release its node installer verifies). const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false }); const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - const origin = switching ? initialCoreUrl : sandboxSetupOrigin(coreUrl); - // The review asks for the Core address only when the console's own cannot serve. - const askOrigin = !switching && !sandboxSetupOrigin(initialCoreUrl); const needsRuntime = provider === "docker" || provider === "microsandbox"; const runtimeReady = !needsRuntime || isRuntimeRelease(release); // Core keeps no key across a change: E2B always needs one. @@ -106,7 +110,7 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin // Core sizes E2B sandboxes from the template build, so E2B sends no resources. const sized = provider !== null && provider !== "e2b"; const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources)); - const ready = provider !== null && origin !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; + const ready = provider !== null && runtimeReady && e2bReady && sizeReady && !disabled && !busy; const order: Step[] = where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); @@ -115,6 +119,7 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin // The saved backend keeps its size and Runtime; another starts from its standard size and this console's Runtime. function choose(next: SandboxProvider) { if (next !== provider) { + setRejection(null); const kept = current ? savedSpecification(next, current.provider, current.specification) : null; setResources(kept?.resources ?? presets(next).standard); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); @@ -126,15 +131,19 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin async function save(event?: FormEvent) { event?.preventDefault(); if (!ready || !provider) return; - setBusy(true); + setBusy(true); setRejection(null); try { await onSubmit({ provider, - core_url: origin!, ...(sized ? { resources } : {}), ...(needsRuntime ? { runtime: release as SandboxRuntimeRelease } : {}), ...(provider === "e2b" ? { e2b: { api_key: apiKey.trim(), template: template.trim() } } : {}), }); + } catch (error) { + // A configuration Core rejected is explained here; the page reports every other failure. + const reason = sandboxConfigurationRejection(error); + if (reason === null) throw error; + setRejection(reason); } finally { setApiKey(""); setBusy(false); @@ -217,17 +226,32 @@ export function SandboxSetupWizard({ initialCoreUrl, current, disabled, switchin
{runtimeReady ? {release.source_commit!.slice(0, 12)} : {t("Runtime release needed")}{t("This console serves no Runtime manifest. Enter the release under advanced settings.")}}
) : null} - {askOrigin ? null : ( -
-
{t("Core address")}{t("The address nodes and sandboxes use to reach Core. It must be HTTPS and reachable from them; the console's own address may differ.")}
-
{origin ? {origin} : {t("Core address needed")}}
-
- )} +
+
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
+
+ {address ? {address} : "—"} + {t("Set by public_url in config.json")} + {installation.data?.local_only ? {t("Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set a public_url in config.json that other machines can reach (not loopback).")} : null} +
+
- {askOrigin ? ( - - setCoreUrl(event.target.value)} placeholder="https://core.example" /> - + {rejection ? ( +
+

{rejection}

+ {configuration ? ( +
+
{t("Config file")}
+
{t("Then run")}
+
+ ) : null} +
+ ) : null} + {/* Every save attempt clears the E2B key, so a second one needs it entered again. */} + {provider === "e2b" && !apiKey.trim() ? ( +

+ {t("Enter the E2B key again to save.")} + +

) : null}
: null} - {!switching ? ( - - setCoreUrl(event.target.value)} placeholder="https://core.example" /> - - ) : null} {needsRuntime ? (
{t("Runtime release")}{t("Filled in from this console's distribution when it serves one. Otherwise copy these from the distribution manifest that matches your nodes; image configuration IDs and manifest digests are different values.")} diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index 411d1c65a..15988daed 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -259,3 +259,68 @@ grid-template-columns: minmax(0, 1fr); } } + +/* Core's reason for rejecting the saved configuration, and where to fix it. */ +.wizard-rejection { + display: grid; + gap: 10px; + padding: 12px 16px; + font-size: 13px; + line-height: 1.5; + background: color-mix(in srgb, var(--danger) 7%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--danger) 28%, var(--line)); + border-radius: var(--radius-card); +} + +.sandbox-wizard .wizard-rejection > p { + margin: 0; + color: var(--ink); +} + +.wizard-rejection dl { + display: grid; + gap: 2px; + margin: 0; +} + +.wizard-rejection dl > div { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + align-items: center; + gap: 12px; +} + +.wizard-rejection dt { + color: var(--ink-3); +} + +.wizard-rejection dd { + min-width: 0; + margin: 0; +} + +.wizard-rejection code { + font-size: 12.5px; +} + +/* A loopback address only the Core machine reaches. */ +.wizard-review-caution { + color: var(--orange); + font-size: 13px; +} + +/* After a save attempt the E2B key is gone; this says so beside the way back to it. */ +.sandbox-wizard .wizard-key-again { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 2px; + margin: 0; + color: var(--ink-2); + font-size: 13px; +} + +.wizard-key-again .wizard-link { + color: var(--accent); +} diff --git a/apps/web/src/features/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx new file mode 100644 index 000000000..25432cf4d --- /dev/null +++ b/apps/web/src/features/system/StartupSettings.tsx @@ -0,0 +1,77 @@ +import type { CoreInstallationConfiguration, CoreInstallationSetting } from "@agents-core-web/agents-client"; +import { Trans, useTranslation } from "react-i18next"; + +import { ValuePill } from "../../components/atoms/ValuePill"; +import { Section } from "../../components/console-ui"; +import { CopyableId } from "../../components/list-ui"; +import { formatDateTime } from "../../lib/format"; + +function display(value: unknown): string | null { + if (value === null || value === undefined) return null; + return typeof value === "string" ? value : JSON.stringify(value); +} + +function isDefault(setting: CoreInstallationSetting): boolean { + return !setting.sensitive && JSON.stringify(setting.value ?? null) === JSON.stringify(setting.default ?? null); +} + +/** + * Platform › System: Core's startup settings, read-only. They live in + * config.json and take effect with the apply command; the console only says + * where to change them. A sensitive setting shows whether it is set, never + * its value. + */ +export function StartupSettings({ configuration }: { configuration: CoreInstallationConfiguration | null }) { + const { t, i18n } = useTranslation("system"); + const locale = i18n.resolvedLanguage; + return ( +
+ {configuration === null ?

{t("startup.none")}

: <> +

+ + , + command: , + }} + /> + + {configuration.applied_at ? {t("startup.appliedAt", { time: formatDateTime(Date.parse(configuration.applied_at) / 1000, locale) })} : null} +

+
+ + + + + + + + + + {configuration.settings.map((setting) => { + const value = setting.sensitive ? null : display(setting.value); + return ( + + + + + + ); + })} + +
{t("startup.columns.key")}{t("startup.columns.value")}{t("startup.columns.restarts")}
{setting.key} + + {setting.sensitive + ? t(setting.configured ? "startup.configured" : "startup.notSet") + : value === null ? {t("startup.notSet")} : {value}} + {isDefault(setting) ? {t("startup.default")} : null} + {setting.changeable ? null : {t("startup.fixed")}} + + {setting.restarts.length ? setting.restarts.join(", ") : {t("startup.noRestart")}}
+
+ } +
+ ); +} diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index e894be83e..dcff96a80 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -6,16 +6,33 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; -import { DetailSkeleton } from "../../components/Skeleton"; +import { CopyableId } from "../../components/list-ui"; +import { TableSkeleton } from "../../components/Skeleton"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatBytes, formatPeriod } from "../../lib/format"; +import { installationQuery } from "../../lib/installation"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "../sandbox/deployment-specification"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; +import { StartupSettings } from "./StartupSettings"; import "./system.css"; const MIB = 2 ** 20; const providerNames: Record = { docker: "Docker", microsandbox: "microsandbox" }; +/** A facts card before its first read; the page announces its loading once. */ +function FactsSkeleton({ facts }: { facts: number }) { + return ( + + ); +} + function Fact({ label, help, children }: { label: string; help?: string; children: ReactNode }) { return (
@@ -29,11 +46,11 @@ function Fact({ label, help, children }: { label: string; help?: string; childre } /** - * Platform › System: the sandbox configuration every project shares — where - * sandboxes run, how big each one is, the Runtime or E2B template build they - * run, microsandbox's idle suspension, the Core address and maintenance. - * Nothing else: harnesses and gateway flags are Core's own startup settings, - * not something an administrator acts on here. + * Platform › System: this installation's addresses, the sandbox configuration + * every project shares — where sandboxes run, how big each one is, the Runtime + * or E2B template build they run, microsandbox's idle suspension and + * maintenance — and Core's startup settings. Each says where it is changed: + * sandboxes on the Nodes page, startup settings in config.json. */ export function SystemPage() { const { t, i18n } = useTranslation("system"); @@ -41,8 +58,9 @@ export function SystemPage() { const { t: tNav } = useTranslation("navigation"); const { navigate } = useConsoleNavigation(); const deployment = useQuery(sandboxDeploymentQuery); + const installation = useQuery(installationQuery); const data = deployment.data ?? null; - const refresh = () => { void deployment.refetch(); }; + const refresh = () => { void deployment.refetch(); void installation.refetch(); }; const spec = data?.specification ?? null; // An E2B selection may adopt its template build's size instead of saving one. const each = data ? sandboxSize(data) : null; @@ -55,7 +73,7 @@ export function SystemPage() { if (!data) { body = deployment.isError && !deployment.isFetching ? - : ; + : ; } else if (!data.provider) { body = ( +
navigate("nodes")}>{t("sandbox.change")}}>
{data.provider === "e2b" ? t("sandbox.e2b") : t("sandbox.ownMachines", { provider: providerNames[data.provider] ?? data.provider })} {each ? ( @@ -92,7 +110,6 @@ export function SystemPage() { {formatPeriod(data.suspension.idle_seconds, locale)} {formatPeriod(data.suspension.retention_seconds, locale)} : null} - {data.core_url ? {data.core_url} : null} {data.maintenance ? : t("values.off")} @@ -101,15 +118,39 @@ export function SystemPage() { ); } + const about = installation.data; + const facts = about ? ( +
+
+ {about.public_url ? {about.public_url} : {t("installation.notSet")}} + + {about.api_base_url ? : {t("installation.notSet")}} + {about.local_only ? {t("installation.localOnly")} : null} + + {about.installation_id ? : {t("installation.unknown")}} + {about.source_commit ? {about.source_commit.slice(0, 12)} : {t("installation.unknown")}} +
+
+ ) : installation.isError && !installation.isFetching + ? void installation.refetch()} /> + : ; + // One announcement while either first read is still out; each slot shows only its own placeholder. + const reading = (!data && !(deployment.isError && !deployment.isFetching)) || (!about && !(installation.isError && !installation.isFetching)); + return (
} + actions={} /> - {body} + + {reading ?

{t("loading")}

: null} + {facts} + {body} + {about ? : installation.isError ? null : } +
); } diff --git a/apps/web/src/features/system/system.css b/apps/web/src/features/system/system.css index 5c935702b..90bd450fb 100644 --- a/apps/web/src/features/system/system.css +++ b/apps/web/src/features/system/system.css @@ -54,3 +54,59 @@ font-size: 12px; overflow-wrap: anywhere; } + +.system-muted { + color: var(--ink-3); +} + +/* Where startup settings are changed: the file and the apply command, both copyable. */ +.system-where { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + margin: 0; + color: var(--ink-2); + font-size: 13px; + line-height: 24px; +} + +/* The file and the command read as chips that carry their own copy button. */ +.system-where .copyable-id { + margin: 0 2px; + padding-left: 8px; + vertical-align: middle; + background: var(--surface); + border-radius: var(--radius-chip); + box-shadow: var(--shadow-hairline); +} + +.system-where .copyable-id code { + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; +} + +/* Setting names and values are the table's data, not secondary IDs. */ +.data-table.system-settings code { + color: var(--ink); + font-size: 12px; +} + +.system-applied { + color: var(--ink-3); + font-size: 12.5px; +} + +.system-note { + margin: 0; + color: var(--ink-2); + font-size: 13px; +} + +.system-setting-value { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; +} diff --git a/apps/web/src/i18n/locales/en/agents.ts b/apps/web/src/i18n/locales/en/agents.ts index d673dfb0e..1d7a8af1d 100644 --- a/apps/web/src/i18n/locales/en/agents.ts +++ b/apps/web/src/i18n/locales/en/agents.ts @@ -37,6 +37,17 @@ export const agents = { textFormat: "Text format", verbosity: "Verbosity", serviceTier: "Service tier", + provider: { + title: "Model provider", + help: "Saved on this Agent. Its Sessions use it unless their request sets a provider. Core never returns the API key.", + protocol: "Protocol", + baseUrl: "Base URL", + apiKey: "API key", + keyConfigured: "Configured", + keyNotConfigured: "Not configured", + contextWindow: "Context window", + maxOutputTokens: "Max output tokens", + }, metadata: "Metadata", noMetadata: "No metadata", usage: "Usage", diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 7f847339c..92350442c 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -93,6 +93,17 @@ export const keys = { uncertain: "Core did not confirm the result. Refresh to check before trying again.", rejected: "Core rejected the request: {{message}}", }, + howToCall: { + title: "How to call", + shell: "Shell", + copy: "Copy the {{label}} sample", + copyFailed: "Select the text and copy it manually.", + loading: "Reading the API address", + failed: "The API address couldn't be read.", + localOnly: "Core's API is only reachable on the Core machine. To call it from elsewhere, set public_url in config.json and apply it.", + noAddress: "Core has no public address yet. Set public_url in config.json and apply it.", + model: "Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default.", + }, detail: { back: "Back", facts: "Project details", diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 6a8edb4c7..7a1e36df5 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -1,9 +1,43 @@ export const system = { - help: "The sandbox configuration every project shares: where sandboxes run, how big each one is and what they run. Change it on the Nodes page.", + help: "This installation's addresses and the settings every project shares. Sandboxes are changed on the Nodes page, startup settings in config.json.", refresh: "Refresh system configuration", loading: "Loading…", + installation: { + title: "Installation", + failed: "The installation could not be read.", + publicUrl: "Public address", + publicUrlHelp: "The address apps, nodes and sandboxes use to reach Core, set as public_url in config.json.", + apiBaseUrl: "API base URL", + apiBaseUrlHelp: "Applications use it as OPENAI_BASE_URL, with a Project API key as OPENAI_API_KEY.", + copyApiBaseUrl: "Copy API base URL", + localOnly: "Only reachable on the Core machine", + notSet: "Not set", + id: "Installation ID", + sourceCommit: "Source commit", + unknown: "Unknown", + }, + startup: { + title: "Startup settings", + help: "Core reads these from config.json when it starts. The console only shows them.", + none: "Core was not started from a config.json, so there are no startup settings to show.", + where: "Change these in , then run ", + copyPath: "Copy path", + copyCommand: "Copy command", + appliedAt: "Last applied {{time}}", + columns: { + key: "Setting", + value: "Value", + restarts: "Restarts", + }, + configured: "Configured", + notSet: "Not set", + default: "Default", + fixed: "Fixed after install", + noRestart: "None", + }, sandbox: { title: "Sandboxes", + change: "Change on the Nodes page", deploymentFailed: "The sandbox deployment could not be loaded.", unconfigured: "Sandboxes are not set up yet", setUp: "Set up on the Nodes page", @@ -24,8 +58,6 @@ export const system = { suspendAfterHelp: "A sandbox idle this long after its Session's last Turn is suspended into a snapshot on its node. The Session's next Turn resumes it.", keepSuspended: "Keep suspended for", keepSuspendedHelp: "How long a suspended sandbox's snapshot is kept on its node.", - coreOrigin: "Core address", - coreOriginHelp: "The address nodes and sandboxes use to reach Core.", maintenance: "Maintenance", maintenanceHelp: "While on, no new sandboxes are placed.", }, diff --git a/apps/web/src/i18n/locales/zh-CN/agents.ts b/apps/web/src/i18n/locales/zh-CN/agents.ts index 349779c07..11b402c81 100644 --- a/apps/web/src/i18n/locales/zh-CN/agents.ts +++ b/apps/web/src/i18n/locales/zh-CN/agents.ts @@ -30,6 +30,17 @@ export const agents: TranslationShape = { textFormat: "文本格式", verbosity: "详细程度", serviceTier: "服务层级", + provider: { + title: "模型服务", + help: "保存在此 Agent 上。它的 Session 使用这里的模型服务,除非请求中另外指定。Core 从不返回 API key。", + protocol: "协议", + baseUrl: "Base URL", + apiKey: "API key", + keyConfigured: "已配置", + keyNotConfigured: "未配置", + contextWindow: "上下文窗口", + maxOutputTokens: "最大输出 token", + }, metadata: "元数据", noMetadata: "没有元数据", usage: "用量", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 2cbf33265..7ec8ccd9c 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -95,6 +95,17 @@ export const keys: TranslationShape = { uncertain: "Core 没有确认结果。请先刷新查看,再决定是否重试。", rejected: "Core 拒绝了请求:{{message}}", }, + howToCall: { + title: "如何调用", + shell: "终端", + copy: "复制示例:{{label}}", + copyFailed: "请选中文本后手动复制。", + loading: "正在读取 API 地址", + failed: "无法读取 API 地址。", + localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + noAddress: "Core 还没有公开地址。请在 config.json 中设置 public_url 并应用。", + model: "运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", + }, detail: { back: "返回", facts: "项目信息", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index a63ae10a9..513cde74c 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -1,11 +1,45 @@ import type { system as english } from "../en/system"; type TranslationShape = { [K in keyof T]: T[K] extends string ? string : TranslationShape }; export const system: TranslationShape = { - help: "所有项目共用的沙箱配置:沙箱在哪里运行、每个多大、运行什么。修改请到“节点”页面。", + help: "这个安装的地址,以及所有项目共用的设置。沙箱在“节点”页面修改,启动设置在 config.json 中修改。", refresh: "刷新系统配置", loading: "正在加载…", + installation: { + title: "安装", + failed: "无法读取安装信息。", + publicUrl: "公开地址", + publicUrlHelp: "应用、节点和沙箱访问 Core 使用的地址,即 config.json 中的 public_url。", + apiBaseUrl: "API 基础地址", + apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", + copyApiBaseUrl: "复制 API 基础地址", + localOnly: "只能在 Core 所在的机器上访问", + notSet: "未设置", + id: "安装 ID", + sourceCommit: "源码提交", + unknown: "未知", + }, + startup: { + title: "启动设置", + help: "Core 启动时从 config.json 读取这些设置。控制台只显示它们。", + none: "Core 不是通过 config.json 启动的,没有可显示的启动设置。", + where: "在 中修改,然后运行 ", + copyPath: "复制路径", + copyCommand: "复制命令", + appliedAt: "上次应用于 {{time}}", + columns: { + key: "设置", + value: "值", + restarts: "重启的服务", + }, + configured: "已设置", + notSet: "未设置", + default: "默认值", + fixed: "安装后不可修改", + noRestart: "无", + }, sandbox: { title: "沙箱", + change: "在“节点”页面修改", deploymentFailed: "无法加载沙箱部署。", unconfigured: "还没有设置沙箱", setUp: "去“节点”页面设置", @@ -26,8 +60,6 @@ export const system: TranslationShape = { suspendAfterHelp: "Session 的最后一个 Turn 结束后,沙箱空闲这么久就会被挂起为节点上的快照;Session 的下一个 Turn 会恢复它。", keepSuspended: "挂起后保留", keepSuspendedHelp: "挂起的沙箱快照在节点上保留的时长。", - coreOrigin: "Core 地址", - coreOriginHelp: "节点和沙箱访问 Core 使用的地址。", maintenance: "维护模式", maintenanceHelp: "开启时不再分配新沙箱。", }, diff --git a/apps/web/src/lib/installation.ts b/apps/web/src/lib/installation.ts new file mode 100644 index 000000000..cad314467 --- /dev/null +++ b/apps/web/src/lib/installation.ts @@ -0,0 +1,13 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { admin } from "./admin-view"; + +/** + * This installation's public address, its `/v1` base URL for applications and + * its config.json startup settings (`/core/v1/installation`). Readable before + * any sandbox deployment exists; the console never writes it. + */ +export const installationQuery = queryOptions({ + queryKey: ["installation"], + queryFn: ({ signal }) => admin.retrieveInstallation({ signal }), +}); diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 310b2cc5d..43cf070c5 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -18,9 +18,7 @@ export const chinese = { "Enter at least the number of sandboxes at once.": "不能少于同时运行的沙箱数。", "E2B runs this deployment's sandboxes in its cloud. There are no machines to add.": "这个部署的沙箱由 E2B 在云端运行,不需要添加机器。", "Saving does not migrate Sessions or resume placement automatically.": "保存不会迁移 Session,也不会自动恢复分配。", - "This console address cannot be used by sandbox guests. Enter the HTTPS Core address that your nodes and guests can reach.": "沙箱无法使用此控制台地址。请输入节点和沙箱都能访问的 HTTPS Core 地址。", "Use an HTTPS Core origin reachable from every node and sandbox guest, without a path or credentials. The console URL may be different.": "请输入所有节点和沙箱都能访问的 HTTPS Core 源地址,不含路径或凭据。此地址可能与控制台地址不同。", - "Enter a non-loopback HTTPS origin, such as https://core.example.": "请输入非回环的 HTTPS 源地址,例如 https://core.example。", "Needs attention": "需要检查", "Done": "完成", "Copy command": "复制命令", @@ -251,7 +249,6 @@ export const chinese = { "Check this value": "请检查这个值", "Connect E2B": "连接 E2B", "Core address": "Core 地址", - "Core address needed": "需要 Core 地址", "Custom size in advanced settings": "在高级设置里自定义规格", "Data disk at /environment (MiB)": "数据盘 /environment(MiB)", "Docker runs each sandbox as a container: quick to set up on any Docker host, with CPU and memory limits but no disk quota. microsandbox runs each sandbox as a lightweight virtual machine: stronger isolation and its own root and data disks with size limits, but the host needs KVM.": "Docker:每个沙箱是一个容器,任何装了 Docker 的主机都能很快用上,能限制 CPU 和内存,但没有磁盘配额。microsandbox:每个沙箱是一个轻量虚拟机,隔离更强,有独立的根盘和数据盘并能限制大小,但主机需要支持 KVM。", @@ -287,7 +284,6 @@ export const chinese = { "After {{idle}} · kept {{retention}}": "{{idle}}后 · 保留 {{retention}}", "The Runtime release every node runs: the saved one while the backend stays the same, otherwise the one this console distributes.": "所有节点运行的 Runtime 版本:后端不变时沿用已保存的版本,否则使用此控制台分发的版本。", "Current": "当前规格", - "The address nodes and sandboxes use to reach Core. It must be HTTPS and reachable from them; the console's own address may differ.": "节点和沙箱访问 Core 用的地址,必须是它们能访问的 HTTPS 地址;可以和控制台自己的地址不同。", "The exact ready build, as template-id:build-uuid. A template alias alone is not enough. Each sandbox gets the build's CPU and memory.": "精确的已就绪构建,格式为 template-id:build-uuid,只填模板别名不行。每个沙箱按这个构建分配 CPU 和内存。", "The key is write-only: Core encrypts it and never shows it again.": "key 只写入:Core 加密保存,之后不再显示。", "This console serves no Runtime manifest. Enter the release under advanced settings.": "这个控制台没有提供 Runtime 清单,请在高级设置里填写。", @@ -298,5 +294,15 @@ export const chinese = { "Save configuration": "保存配置", "Save and stay in maintenance": "保存并保持维护模式", "Change the sandbox configuration": "修改沙箱配置", + "The address nodes and sandboxes use to reach Core.": "节点和沙箱访问 Core 使用的地址。", + "Set by public_url in config.json": "由 config.json 中的 public_url 设置", + "Config file": "配置文件", + "Then run": "然后运行", + "Copy path": "复制路径", + "Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set a public_url in config.json that other machines can reach (not loopback).": "只有 Core 所在的机器能访问这个地址,其他机器上的节点和 E2B 沙箱都无法访问。请在 config.json 里设置一个其他机器能访问的 public_url(不能是回环地址)。", + "Enter the E2B key again to save.": "请重新输入 E2B key 后再保存。", + "Enter the key": "输入 key", + "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", + "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", } as const; export type MessageKey = keyof typeof chinese; diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 04e318770..8359449e9 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -25,6 +25,15 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { return translate(locale, key); } +/** + * Core's own reason when it rejects a deployment configuration it cannot serve, + * such as E2B with a loopback public_url; null for any other failure. Nothing + * was saved, so the administrator corrects the cause and saves again. + */ +export function sandboxConfigurationRejection(error: unknown): string | null { + return error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error" && error.message ? error.message : null; +} + export function sandboxNodeStatus(node: SandboxNode, stale: boolean, locale: Locale): string { return translate(locale, stale ? "Status unconfirmed" : !node.online ? "Offline" : node.provider_ready ? "Available" : "Unavailable"); } diff --git a/docs/web/README.md b/docs/web/README.md index 1308418ab..85dcff6f8 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -21,7 +21,7 @@ management client, and it sends nothing to `/v1`. | Monitor | Sandbox metrics | Node capacity and hosted Runtime CPU and memory across Projects | | Monitor | Session log | Every Session, opening one Session's read-only conversation, trace and Turns; a self-hosted Session's page also manages its executor credentials | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion | -| Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension, Core address and maintenance | +| Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System, read-only: the installation's public address, API base URL, ID and source commit, Core's config.json startup settings with where to change them, and the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and maintenance | Missing data is shown as missing (—), never as zero. How each figure is read and bounded is recorded in [management interface coverage](protocol-coverage.md). diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 4fc7c1c20..cd0b0a167 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -16,9 +16,9 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | | 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; the node installer (`node_installer`, `node_installer_sha256`) | -| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics | +| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics, the installation | | Sandbox administration | `/core/v1/sandbox/**` | Core key, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | -| Agents API | `/v1/**` | Project API key | Not used; the console never sends it | +| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives shell exports of `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key) with `curl` and Python examples for `/v1/agents`, and sends none of them; when the installation is `local_only` it says the API is reachable only on the Core machine, and without an `api_base_url` it says to set `public_url` | 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 @@ -52,7 +52,7 @@ here. Archived projects remain readable. | Resource | Reads used | Deletion | Creator | Console surface | | --- | --- | --- | --- | --- | -| Agents | `/agents`, `/agents/{agent_id}` | Agent | `agent` | Agents list with usage per Agent; Agent page with instructions, tools, generation settings and metadata | +| Agents | `/agents`, `/agents/{agent_id}` | Agent | `agent` | Agents list with usage per Agent; Agent page with instructions, tools, the saved model provider (never its key), generation settings and metadata | | Environment templates | `/environment-templates`, `/environment-templates/{id}` | Template | `environment_template` | Templates list; Template page with every safe section | | Skills | `/skills`, `/skills/{skill_id}`, `/skills/{skill_id}/versions`, Skill and version `/content` | Skill and Skill version | `skill` (list) | Skills list; Skill page with versions and archive downloads | | Files | `/files` | File | `file` | Files list (metadata only) | @@ -106,21 +106,22 @@ allows. | Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | +| Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings` under its `path`, `apply_command` and `applied_at`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the how-to-call samples under a new key; `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. The administrator audit -log (`GET /core/v1/audit-log`) is not consumed; System shows the sandbox -deployment only. +log (`GET /core/v1/audit-log`) is not consumed; System shows the installation and +the sandbox deployment. ## Sandbox administration | Operation | Route | Console use | | --- | --- | --- | -| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, Core origin, maintenance state, installation ID and specification; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | +| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), maintenance state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows Core's message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | Maintenance | `PATCH /core/v1/sandbox/deployment/maintenance` | Enter or leave maintenance to change the provider | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. Under microsandbox, a node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase and, while suspended, about when Core reclaims it (that time plus the deployment's `suspension.retention_seconds`); a null time shows a dash | | Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum |