From 32b661e8a7eafe9de008bbbc2066854ac3e73a11 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:20:19 +0000 Subject: [PATCH 01/18] Read Core's address from config.json on the Nodes page The sandbox wizard no longer asks for a Core address or sends core_url: its review shows the deployment's read-only address (config.json's public_url) with where it is set. When Core rejects a configuration it cannot serve (409 sandbox_configuration_error, such as E2B without an HTTPS public_url), the wizard keeps Core's message and adds the config file and apply command from GET /core/v1/installation; nothing is marked uncertain. Nodes warns when nodes are still bound to an old address. --- .../sandbox/SandboxDeploymentSettings.tsx | 2 +- .../features/sandbox/SandboxManagerView.css | 7 ++ .../features/sandbox/SandboxManagerView.tsx | 19 ++++-- .../features/sandbox/SandboxSetupWizard.tsx | 64 +++++++++++-------- .../src/features/sandbox/sandbox-wizard.css | 43 +++++++++++++ apps/web/src/lib/installation.ts | 13 ++++ apps/web/src/lib/locale-strings.ts | 11 ++-- apps/web/src/lib/sandbox-labels.ts | 9 +++ 8 files changed, 128 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/lib/installation.ts 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); @@ -102,8 +104,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 +126,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 +137,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 })); @@ -255,9 +259,10 @@ 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} + {hostedNodes && staleNodes > 0 ?

{staleNodes === 1 ? t("1 node is still bound to an old Core address. Add it again.") : t("{{count}} nodes are still bound to an old Core address. Add them again.", { count: 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..2f9a64390 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 an E2B deployment without an HTTPS 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,21 @@ 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")}
+
- {askOrigin ? ( - - setCoreUrl(event.target.value)} placeholder="https://core.example" /> - + {rejection ? ( +
+

{rejection}

+ {configuration ? ( +
+
{t("Config file")}
+
{t("Then run")}
+
+ ) : null} +
) : null}

+ :

{t("howToCall.loading")}

; + } else if (base === null) { + body =

{t("howToCall.localOnly")}

; + } else { + const samples = callSamples(base, apiKey); + body = <> + + + +

{t("howToCall.model")}

+ ; + } + return ( +
+

{t("howToCall.title")}

+ {body} +
+ ); +} + +function CodeSample({ label, value }: { label: string; value: string }) { + const { t } = useTranslation("keys"); + return ( +
+
+ {label} + +
+
{value}
+
+ ); +} 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..501219b54 --- /dev/null +++ b/apps/web/src/features/api-keys/how-to-call.css @@ -0,0 +1,56 @@ +/* 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; +} diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 7f847339c..f0a663bca 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -93,6 +93,15 @@ 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", + env: "Environment", + copy: "Copy {{label}}", + loading: "Reading the API address…", + failed: "The API address couldn't be read.", + localOnly: "Core's API is only reachable on this machine. To call it from elsewhere, set public_url in config.json and apply it.", + model: "Running an Agent needs a model: set a default model on the System page, or put the model fields in each request.", + }, detail: { back: "Back", facts: "Project details", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 2cbf33265..8b7040c56 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -95,6 +95,15 @@ export const keys: TranslationShape = { uncertain: "Core 没有确认结果。请先刷新查看,再决定是否重试。", rejected: "Core 拒绝了请求:{{message}}", }, + howToCall: { + title: "如何调用", + env: "环境变量", + copy: "复制 {{label}}", + loading: "正在读取 API 地址…", + failed: "无法读取 API 地址。", + localOnly: "Core 的 API 只能在本机访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + model: "运行 Agent 需要模型:在“系统”页面设置默认模型,或在每个请求里填写模型字段。", + }, detail: { back: "返回", facts: "项目信息", From d26ddd23c13d6fb20f48c339c1a5039ca67931c0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:20:19 +0000 Subject: [PATCH 03/18] Show installation facts and startup settings on System System lists the public address, API base URL, installation ID and source commit, then the config.json startup settings read-only: each value (sensitive ones only as configured or not set), default and fixed markers and the services a change restarts, under the copyable file and apply command. The sandbox section links to the Nodes page where it changes. --- .../src/features/system/StartupSettings.tsx | 77 +++++++++++++++++++ apps/web/src/features/system/SystemPage.tsx | 41 +++++++--- apps/web/src/features/system/system.css | 56 ++++++++++++++ apps/web/src/i18n/locales/en/system.ts | 38 ++++++++- apps/web/src/i18n/locales/zh-CN/system.ts | 38 ++++++++- 5 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/features/system/StartupSettings.tsx 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..878fb9399 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -6,11 +6,14 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; +import { CopyableId } from "../../components/list-ui"; import { DetailSkeleton } 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; @@ -29,11 +32,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 +44,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; @@ -66,7 +70,7 @@ export function SystemPage() { ); } else { body = ( -
+
navigate("nodes")}>{t("sandbox.change")}}>
{data.provider === "e2b" ? t("sandbox.e2b") : t("sandbox.ownMachines", { provider: providerNames[data.provider] ?? data.provider })} {each ? ( @@ -92,7 +96,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 +104,33 @@ 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.localOnly")}} + {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()} /> + : ; + return (
} + actions={} /> - {body} + + {facts} + {body} + {about ? : 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/system.ts b/apps/web/src/i18n/locales/en/system.ts index 6a8edb4c7..0af76c477 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 one HTTPS address apps, nodes and sandboxes use to reach Core: 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 this 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/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index a63ae10a9..60c305e47 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 使用的唯一 HTTPS 地址,即 config.json 中的 public_url。", + apiBaseUrl: "API 基础地址", + apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", + copyApiBaseUrl: "复制 API 基础地址", + localOnly: "只能在本机访问", + 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: "开启时不再分配新沙箱。", }, From 9642ec5e558fd99eb4c33d12916821d2dc0b6a8d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:20:19 +0000 Subject: [PATCH 04/18] Cover the public address and installation read in browser acceptance The fixture serves GET /core/v1/installation (public, local-only and stale-node modes), rejects core_url on deployment writes with 400, returns a read-only core_url and rejects E2B with 409 sandbox_configuration_error when public_url is not HTTPS. Acceptance covers the wizard without an address field, the E2B rejection, the how-to-call card, the startup settings and the stale-node warning. --- apps/web/e2e/console.ts | 8 ++-- apps/web/e2e/fixture-console.mjs | 71 ++++++++++++++++++++++++++------ apps/web/e2e/nodes.spec.ts | 3 +- apps/web/e2e/projects.spec.ts | 6 +++ apps/web/e2e/public-url.spec.ts | 56 +++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 apps/web/e2e/public-url.spec.ts diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 0c3c9c5b7..7e56fa0cf 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -8,13 +8,15 @@ 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, so the API has no + * public base URL 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/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 95ab9cc32..2008d57b1 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,42 @@ 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"; +const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); + +/** 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.length : 0; + return { + object: "core.installation", installation_id: "7f3c2a90-fixture", public_url: publicUrl(), api_base_url: local ? null : `${PUBLIC_URL}/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"]), + ], + }, + address_bindings: { nodes, nodes_on_other_address: state.installation === "stale" ? Math.min(1, nodes) : 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, + }; +} + 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: "7f3c2a90-fixture", 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 +70,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 +86,12 @@ 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(); if (sandbox === "e2b") Object.assign(state, { nodes: [], allocations: [] }); } reset(); @@ -63,11 +100,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 +210,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 +273,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"; + 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"); + // As Core: the address is config.json's public_url and read-only. + if ("core_url" in input) return error(response, 400, "core_url is read-only; set public_url in config.json.", "invalid_sandbox_configuration", "core_url"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); + // E2B sandboxes reach Core over the internet, which a loopback or HTTP public_url cannot serve. + if (e2b && !publicUrl().startsWith("https://")) return error(response, 409, "E2B sandboxes need an HTTPS public_url. Set public_url in config.json, then run parsar apply.", "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, initialize ? 201 : 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: "7f3c2a90-fixture", 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") { @@ -341,7 +386,7 @@ function registeredNode(nodeId) { 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..b102f4cb3 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -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. @@ -155,6 +154,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..b12bde08f 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("Environment", { exact: true })).toHaveText(`OPENAI_BASE_URL=https://core.example.com/v1\nOPENAI_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..e0100bb1c --- /dev/null +++ b/apps/web/e2e/public-url.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, openConsole } 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(); + await expect(page.getByText("c0ffee000000")).toBeVisible(); + 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(); + await expect(page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" })).toBeVisible(); + await page.getByRole("button", { name: "Save configuration" }).click(); + const rejection = page.locator(".wizard-rejection"); + await expect(rejection).toContainText("E2B sandboxes need an HTTPS public_url."); + 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(); +}); + +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 nodes bound to an old Core address", async ({ page, request }) => { + await openConsole(page, request, "nodes", { installation: "stale" }); + await expect(page.getByRole("alert").filter({ hasText: "old Core address" })).toHaveText("1 node is still bound to an old Core address. Add it again."); +}); From b2023f851817ad4360d0d6a241d67b3386ea175f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:27:00 +0000 Subject: [PATCH 05/18] Describe the read-only public address and installation read in Web docs DESIGN.md: the wizard shows Core's address read-only from config.json's public_url and explains an E2B rejection inline; the how-to-call card; the System page's sections; the Nodes old-address warning. PRODUCT.md: System and the OPENAI_BASE_URL/OPENAI_API_KEY guidance beside a new key. docs/web: the Platform row, the Agents API row, a GET /core/v1/installation row and the deployment row's read-only core_url. --- apps/web/DESIGN.md | 27 ++++++++++++++++++++++++--- apps/web/PRODUCT.md | 10 ++++++---- docs/web/README.md | 2 +- docs/web/protocol-coverage.md | 11 ++++++----- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index fe0aec7dd..dffc3d1bb 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -504,6 +504,12 @@ 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: `OPENAI_BASE_URL` (the installation's API base URL) and + `OPENAI_API_KEY` (the new key), a curl request and a Python example with the + pinned SDK. One Graphite line says execution needs a model. Without a public API + address the card only says the API is reachable on the Core machine alone. ### Navigation Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; @@ -567,7 +573,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 nodes still bound to an old Core address on the Nodes page, 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 +625,26 @@ 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. When Core rejects the configuration for it (E2B without an HTTPS +`public_url`), a red-tinted block under the review keeps Core's message and adds +the config file and apply command as copyable values. 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..b7c62fe08 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 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..e49b63f6c 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 `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 neither; without an `api_base_url` it says the API is reachable only on the Core machine | 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 @@ -106,19 +106,20 @@ 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; `address_bindings.nodes_on_other_address` for the Nodes warning about nodes bound to an old address. 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` (such as E2B without an HTTPS `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 | | 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 | From 76948f5b8f83cbafe2505b9547e0957f1b7eda6c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:03:57 +0000 Subject: [PATCH 06/18] Match the Web to the backend's installation and node address client The real client reports each node's enrolled core_url and requires api_base_url to be public_url followed by /v1, so a loopback install has a local API base URL and local_only set. The how-to-call card then shows the samples under a note that only the Core machine reaches them, and System marks the API base URL the same way. Test node fixtures carry core_url. The e2e fixture uses one UUID installation ID, derives api_base_url and the stale-node count from the nodes' core_url, and the add-node E2B test no longer fills the removed address field. --- apps/web/DESIGN.md | 5 +++-- apps/web/e2e/console.ts | 5 +++-- apps/web/e2e/fixture-console.mjs | 19 +++++++++++++------ apps/web/e2e/nodes.spec.ts | 3 +-- apps/web/src/features/api-keys/HowToCall.tsx | 4 +++- .../src/features/overview/test-fixtures.ts | 2 +- apps/web/src/features/system/SystemPage.tsx | 5 ++++- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- docs/web/protocol-coverage.md | 2 +- 12 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index dffc3d1bb..361d322c5 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -508,8 +508,9 @@ request runs. copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key), a curl request and a Python example with the - pinned SDK. One Graphite line says execution needs a model. Without a public API - address the card only says the API is reachable on the Core machine alone. + pinned SDK. One Graphite line says execution needs a model. When the public + address is loopback, a note above the samples says the API is reachable only on + the Core machine; without an API address only that note shows. ### Navigation Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 7e56fa0cf..d4253641a 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -9,8 +9,9 @@ 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, `nodes: "none"` a deployment no node has joined, and - * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" (loopback, so the API has no - * public base URL and E2B is rejected) or "stale" (public, with a node enrolled with an earlier address). + * `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"; installation?: "public" | "local" | "stale" } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 2008d57b1..7432fcc62 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -32,16 +32,21 @@ const manifest = { platform: "linux/amd64", source_commit: release.source_commit */ 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.length : 0; + const nodes = state.deployment?.provider && state.deployment.provider !== "e2b" ? state.nodes : []; return { - object: "core.installation", installation_id: "7f3c2a90-fixture", public_url: publicUrl(), api_base_url: local ? null : `${PUBLIC_URL}/v1`, + // 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", @@ -55,12 +60,12 @@ function installation() { secret("database_url", ["core"]), ], }, - address_bindings: { nodes, nodes_on_other_address: state.installation === "stale" ? Math.min(1, nodes) : 0, hosted_sandboxes: 0, self_hosted_executors: 0 }, + address_bindings: { nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, hosted_sandboxes: 0, self_hosted_executors: 0 }, }; } function configuredDeployment() { - return { installation_id: "7f3c2a90-fixture", 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 }; + 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 } }; @@ -92,6 +97,8 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d 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(); @@ -296,7 +303,7 @@ async function sandboxRoute(request, response, path) { return send(response, initialize ? 201 : 200, state.deployment); } if (path === "/deployment") { - return send(response, 200, state.deployment ?? { installation_id: "7f3c2a90-fixture", provider: "", core_url: publicUrl(), 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") { @@ -379,7 +386,7 @@ 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. */ diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index b102f4cb3..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"); @@ -124,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); diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx index 8ba7168a4..48d47ff9c 100644 --- a/apps/web/src/features/api-keys/HowToCall.tsx +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -34,7 +34,8 @@ export function callSamples(apiBaseUrl: string, apiKey: string) { /** * 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. Without a public API address it says so instead of guessing one. + * requests. When Core is reachable only on its own machine it says so, and + * without an API address it shows nothing to guess from. */ export function HowToCall({ apiKey }: { apiKey: string }) { const { t } = useTranslation("keys"); @@ -53,6 +54,7 @@ export function HowToCall({ apiKey }: { apiKey: string }) { } else { const samples = callSamples(base, apiKey); body = <> + {installation.data.local_only ?

{t("howToCall.localOnly")}

: null} 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/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 878fb9399..0174e3fb0 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -109,7 +109,10 @@ export function SystemPage() {
{about.public_url ? {about.public_url} : {t("installation.notSet")}} - {about.api_base_url ? : {t("installation.localOnly")}} + + {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")}}
diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index f0a663bca..b9f101db3 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -99,7 +99,7 @@ export const keys = { copy: "Copy {{label}}", loading: "Reading the API address…", failed: "The API address couldn't be read.", - localOnly: "Core's API is only reachable on this machine. To call it from elsewhere, set public_url in config.json and apply it.", + 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.", model: "Running an Agent needs a model: set a default model on the System page, or put the model fields in each request.", }, detail: { diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 0af76c477..e944aafa0 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -10,7 +10,7 @@ export const system = { 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 this machine", + localOnly: "Only reachable on the Core machine", notSet: "Not set", id: "Installation ID", sourceCommit: "Source commit", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 8b7040c56..4b56cfd33 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -101,7 +101,7 @@ export const keys: TranslationShape = { copy: "复制 {{label}}", loading: "正在读取 API 地址…", failed: "无法读取 API 地址。", - localOnly: "Core 的 API 只能在本机访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", model: "运行 Agent 需要模型:在“系统”页面设置默认模型,或在每个请求里填写模型字段。", }, detail: { diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 60c305e47..bc7c9aa8b 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -12,7 +12,7 @@ export const system: TranslationShape = { apiBaseUrl: "API 基础地址", apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", copyApiBaseUrl: "复制 API 基础地址", - localOnly: "只能在本机访问", + localOnly: "只能在 Core 所在的机器上访问", notSet: "未设置", id: "安装 ID", sourceCommit: "源码提交", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index e49b63f6c..de10a45ba 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -18,7 +18,7 @@ the administrator API are defined by the [administrator API contract](../../cont | 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, 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. Wherever a new key is shown, the console gives `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 neither; without an `api_base_url` it says the API is reachable only on the Core machine | +| Agents API | `/v1/**` | Project API key | Not used. Wherever a new key is shown, the console gives `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 neither; when the installation is `local_only`, or has no `api_base_url`, it says the API is reachable only on the Core machine | 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 From 11c16456e96978b7ccf0e3b5ab93dace2938e7a4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:24:41 +0000 Subject: [PATCH 07/18] Name every model provider source on the how-to-call card The card no longer points at a System page setting that ships separately: a model provider comes with each request, from the saved Agent or from the deployment default. --- apps/web/DESIGN.md | 3 ++- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 361d322c5..a1f8f41ec 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -508,7 +508,8 @@ request runs. copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key), a curl request and a Python example with the - pinned SDK. One Graphite line says execution needs a model. When the public + pinned SDK. 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 an API address only that note shows. diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index b9f101db3..0e4599d59 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -100,7 +100,7 @@ export const keys = { 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.", - model: "Running an Agent needs a model: set a default model on the System page, or put the model fields in each request.", + 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", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 4b56cfd33..4c9c25eea 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -102,7 +102,7 @@ export const keys: TranslationShape = { loading: "正在读取 API 地址…", failed: "无法读取 API 地址。", localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", - model: "运行 Agent 需要模型:在“系统”页面设置默认模型,或在每个请求里填写模型字段。", + model: "运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", }, detail: { back: "返回", From 39400cd33ab4baf75d349a1aa8e5e03abb865da7 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:26:14 +0000 Subject: [PATCH 08/18] Name the nodes on an old Core address from the node list The Nodes warning now comes from the node snapshot the page already reads: a node whose core_url differs from the deployment's core_url (config.json's public_url). It updates with every refresh, removal and enrollment, names the nodes and says to remove and add them again, as a status rather than an alert. Refreshing the page also re-reads the installation the wizard uses. --- apps/web/DESIGN.md | 2 +- apps/web/e2e/public-url.spec.ts | 10 ++++++++-- .../web/src/features/sandbox/SandboxManagerView.tsx | 13 +++++++++---- apps/web/src/lib/locale-strings.ts | 4 ++-- docs/web/protocol-coverage.md | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a1f8f41ec..a13973584 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -576,7 +576,7 @@ reported in an error toast with the reason. Only when a page or section has noth 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. A standing warning that needs action, -such as nodes still bound to an old Core address on the Nodes page, is an +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. diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index e0100bb1c..25cb75903 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -50,7 +50,13 @@ test("lists the startup settings on System with where to change them", async ({ await expect(settings.getByRole("row", { name: /^public_url/ })).toContainText("core, web"); }); -test("warns on Nodes about nodes bound to an old Core address", async ({ page, request }) => { +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" }); - await expect(page.getByRole("alert").filter({ hasText: "old Core address" })).toHaveText("1 node is still bound to an old Core address. Add it again."); + 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 page.getByRole("button", { name: "Refresh sandbox state" }).click(); + await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01"); + await expect(warning).toHaveCount(0); }); diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index a10a0d609..2d9c99a5d 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -60,8 +60,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const client = sandboxAdmin; const queryClient = useQueryClient(); const query = useQuery(sandboxSnapshotQuery); - // Nodes enrolled with an address other than the current public_url must be added again. - const staleNodes = useQuery(installationQuery).data?.address_bindings.nodes_on_other_address ?? 0; const snapshot: SandboxSnapshot | null = query.data ?? null; const loading = query.isFetching; // As before a reload clears the last error, a running read hides it. @@ -87,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" }); }); @@ -184,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; @@ -262,7 +265,9 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {snapshot && !snapshot.deployment.provider ? : null} {snapshot?.deployment.provider ? <> {snapshot.deployment.maintenance ?

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

: null} - {hostedNodes && staleNodes > 0 ?

{staleNodes === 1 ? t("1 node is still bound to an old Core address. Add it again.") : t("{{count}} nodes are still bound to an old Core address. Add them again.", { count: staleNodes })}

: 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: staleNodes.join(", ") })}

: null} {hostedNodes ?
{nodes.length diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 96f2ce617..e5aaa3f36 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -299,7 +299,7 @@ export const chinese = { "Config file": "配置文件", "Then run": "然后运行", "Copy path": "复制路径", - "1 node is still bound to an old Core address. Add it again.": "有 1 个节点仍绑定在旧的 Core 地址上,需要重新添加。", - "{{count}} nodes are still bound to an old Core address. Add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上,需要重新添加。", + "{{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/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index de10a45ba..e988a4b08 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -106,7 +106,7 @@ 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; `address_bindings.nodes_on_other_address` for the Nodes warning about nodes bound to an old address. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | +| 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 @@ -121,7 +121,7 @@ the sandbox deployment. | --- | --- | --- | | 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` (such as E2B without an HTTPS `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 | From d8b620e49094cb661185696b44922213b0afb2a8 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:27:45 +0000 Subject: [PATCH 09/18] Export the how-to-call variables together and report failed copies The Shell sample exports OPENAI_BASE_URL and OPENAI_API_KEY together, so a shell that already exports one of them cannot send the new key to another base URL. Each sample's copy button reports a refused clipboard and selects the sample for a manual copy, as the key's own button does. Without a public address the card says to set public_url, and a skeleton replaces the loading line. --- apps/web/DESIGN.md | 15 +++++--- apps/web/e2e/projects.spec.ts | 2 +- apps/web/src/features/api-keys/HowToCall.tsx | 38 ++++++++++++++----- .../web/src/features/api-keys/how-to-call.css | 23 +++++++++++ apps/web/src/i18n/locales/en/keys.ts | 6 ++- apps/web/src/i18n/locales/zh-CN/keys.ts | 6 ++- docs/web/protocol-coverage.md | 2 +- 7 files changed, 70 insertions(+), 22 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a13973584..a7387ff7e 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -506,12 +506,15 @@ request runs. 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: `OPENAI_BASE_URL` (the installation's API base URL) and - `OPENAI_API_KEY` (the new key), a curl request and a Python example with the - pinned SDK. 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 an API address only that note shows. + 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; diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index b12bde08f..ffd6986da 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -19,7 +19,7 @@ test("creates a project, shows a new key once, revokes it and archives the proje // 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("Environment", { exact: true })).toHaveText(`OPENAI_BASE_URL=https://core.example.com/v1\nOPENAI_API_KEY=${key}`); + 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(); diff --git a/apps/web/src/features/api-keys/HowToCall.tsx b/apps/web/src/features/api-keys/HowToCall.tsx index 48d47ff9c..0b6f128d4 100644 --- a/apps/web/src/features/api-keys/HowToCall.tsx +++ b/apps/web/src/features/api-keys/HowToCall.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { useId } from "react"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { CopyIdButton } from "../../components/list-ui"; 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). */ @@ -11,11 +12,13 @@ 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. + * 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 { - env: `OPENAI_BASE_URL=${apiBaseUrl}\nOPENAI_API_KEY=${apiKey}`, + shell: `export OPENAI_BASE_URL=${apiBaseUrl}\nexport OPENAI_API_KEY=${apiKey}`, curl: [ 'curl "$OPENAI_BASE_URL/agents" \\', ' -H "Authorization: Bearer $OPENAI_API_KEY" \\', @@ -35,7 +38,7 @@ export function callSamples(apiBaseUrl: string, apiKey: string) { * 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 an API address it shows nothing to guess from. + * without a public address it says to set one instead of guessing. */ export function HowToCall({ apiKey }: { apiKey: string }) { const { t } = useTranslation("keys"); @@ -48,14 +51,15 @@ export function HowToCall({ apiKey }: { apiKey: string }) { if (installation.data === undefined) { body = installation.isError && !installation.isFetching ?

{t("howToCall.failed")}

- :

{t("howToCall.loading")}

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

{t("howToCall.localOnly")}

; + body =

{t("howToCall.noAddress")}

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

{t("howToCall.localOnly")}

: null} - +

{t("howToCall.model")}

@@ -69,15 +73,29 @@ export function HowToCall({ apiKey }: { apiKey: string }) { ); } +/** 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}
+
{value}
+ {state === "failed" ?

{t("howToCall.copyFailed")}

: null}
); } diff --git a/apps/web/src/features/api-keys/how-to-call.css b/apps/web/src/features/api-keys/how-to-call.css index 501219b54..95c780f23 100644 --- a/apps/web/src/features/api-keys/how-to-call.css +++ b/apps/web/src/features/api-keys/how-to-call.css @@ -54,3 +54,26 @@ 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/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 0e4599d59..06dbe3b5a 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -95,11 +95,13 @@ export const keys = { }, howToCall: { title: "How to call", - env: "Environment", + shell: "Shell", copy: "Copy {{label}}", - loading: "Reading the API address…", + 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: { diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 4c9c25eea..336436313 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -97,11 +97,13 @@ export const keys: TranslationShape = { }, howToCall: { title: "如何调用", - env: "环境变量", + shell: "终端", copy: "复制 {{label}}", - loading: "正在读取 API 地址…", + copyFailed: "请选中文本后手动复制。", + loading: "正在读取 API 地址", failed: "无法读取 API 地址。", localOnly: "Core 的 API 只能在 Core 所在的机器上访问。要从其他机器调用,请在 config.json 中设置 public_url 并应用。", + noAddress: "Core 还没有公开地址。请在 config.json 中设置 public_url 并应用。", model: "运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。", }, detail: { diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index e988a4b08..27275dfba 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -18,7 +18,7 @@ the administrator API are defined by the [administrator API contract](../../cont | 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, 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. Wherever a new key is shown, the console gives `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 neither; when the installation is `local_only`, or has no `api_base_url`, it says the API is reachable only on the Core machine | +| 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 From e71c1dbc69328ae04ad11fc29e929afcaf17cab1 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:29:30 +0000 Subject: [PATCH 10/18] Say why Save waits and when the address is loopback in the wizard After a save attempt clears the E2B key, the review says to enter it again and links back to the E2B step instead of leaving Save silently disabled. A loopback public_url carries a caution that only the Core machine reaches it. The E2B rejection test also checks that the key is gone from the page. --- apps/web/DESIGN.md | 8 ++++--- apps/web/e2e/public-url.spec.ts | 7 +++++- .../features/sandbox/SandboxSetupWizard.tsx | 13 ++++++++++- .../src/features/sandbox/sandbox-wizard.css | 22 +++++++++++++++++++ apps/web/src/lib/locale-strings.ts | 3 +++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a7387ff7e..7901c2d0f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -631,9 +631,11 @@ 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, read-only: it is config.json's `public_url`, and the console never -asks for it. When Core rejects the configuration for it (E2B without an HTTPS -`public_url`), a red-tinted block under the review keeps Core's message and adds -the config file and apply command as copyable values. +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 without an +HTTPS `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 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 diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 25cb75903..44b893735 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -25,7 +25,8 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com 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 expect(page.getByRole("definition").filter({ hasText: "http://127.0.0.1:8091" })).toBeVisible(); + 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 need an HTTPS public_url."); @@ -34,6 +35,10 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com // 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 }) => { diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 2f9a64390..ab4152752 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -228,7 +228,11 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal ) : null}
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
-
{address ? {address} : "—"}{t("Set by public_url in config.json")}
+
+ {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 an HTTPS public_url in config.json.")} : null} +
{rejection ? ( @@ -242,6 +246,13 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal ) : 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} diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index e57a77858..15988daed 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -302,3 +302,25 @@ .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/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index e5aaa3f36..686da8057 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -299,6 +299,9 @@ export const chinese = { "Config file": "配置文件", "Then run": "然后运行", "Copy path": "复制路径", + "Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set an HTTPS public_url in config.json.": "只有 Core 所在的机器能访问这个地址,其他机器上的节点和 E2B 沙箱都无法访问。请在 config.json 中设置 HTTPS 的 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; From aed8f9900d01dfdf4bd731ea9ee752107f1c2133 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:30:28 +0000 Subject: [PATCH 11/18] Answer the fixture's address errors as Core does A write that sends core_url gets Core's 400 (invalid_request_error, param core_url, its message), and E2B on a loopback public_url gets ErrSandboxPublicURLUnreachable's message. The installation's binding counts follow the fixture's allocations and executor credentials. --- apps/web/e2e/fixture-console.mjs | 13 +++++++++---- apps/web/e2e/public-url.spec.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 7432fcc62..af7c3c11c 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -60,7 +60,12 @@ function installation() { secret("database_url", ["core"]), ], }, - address_bindings: { nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, hosted_sandboxes: 0, self_hosted_executors: 0 }, + // As Core counts them: the allocations held on the nodes, and the unrevoked executor credentials. + address_bindings: { + nodes: nodes.length, nodes_on_other_address: nodes.filter((node) => node.core_url !== publicUrl()).length, + hosted_sandboxes: nodes.length ? state.allocations.length : 0, + self_hosted_executors: [...state.executorCredentials.values()].flat().filter((credential) => credential.revoked_at === null).length, + }, }; } @@ -286,11 +291,11 @@ async function sandboxRoute(request, response, path) { 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"); // As Core: the address is config.json's public_url and read-only. - if ("core_url" in input) return error(response, 400, "core_url is read-only; set public_url in config.json.", "invalid_sandbox_configuration", "core_url"); + 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"); const e2b = input.provider === "e2b"; if (!e2b && (!input.resources || !input.runtime)) return error(response, 400, "resources and runtime are required.", "invalid_sandbox_configuration"); - // E2B sandboxes reach Core over the internet, which a loopback or HTTP public_url cannot serve. - if (e2b && !publicUrl().startsWith("https://")) return error(response, 409, "E2B sandboxes need an HTTPS public_url. Set public_url in config.json, then run parsar apply.", "sandbox_configuration_error"); + // 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), then save again.", "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 = { diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 44b893735..da4514963 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -29,7 +29,7 @@ test("explains an E2B rejection in the wizard, with the file to edit and the com 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 need an HTTPS public_url."); + 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. From 09acacdff4b98730c2b441de77d338cff99eb844 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:30:35 +0000 Subject: [PATCH 12/18] Describe the public address without assuming HTTPS on System A local install's public_url is a loopback HTTP address, so the help no longer calls it the one HTTPS address. --- apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index e944aafa0..7a1e36df5 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -6,7 +6,7 @@ export const system = { title: "Installation", failed: "The installation could not be read.", publicUrl: "Public address", - publicUrlHelp: "The one HTTPS address apps, nodes and sandboxes use to reach Core: public_url in config.json.", + 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", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index bc7c9aa8b..513cde74c 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -8,7 +8,7 @@ export const system: TranslationShape = { title: "安装", failed: "无法读取安装信息。", publicUrl: "公开地址", - publicUrlHelp: "应用、节点和沙箱访问 Core 使用的唯一 HTTPS 地址,即 config.json 中的 public_url。", + publicUrlHelp: "应用、节点和沙箱访问 Core 使用的地址,即 config.json 中的 public_url。", apiBaseUrl: "API 基础地址", apiBaseUrlHelp: "应用把它设为 OPENAI_BASE_URL,并把项目 API key 设为 OPENAI_API_KEY。", copyApiBaseUrl: "复制 API 基础地址", From 36f340a4e3fdda3e68f94040a962e73377b1035e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:50:59 +0000 Subject: [PATCH 13/18] Describe the E2B address rule as loopback, not HTTPS Core rejects E2B whenever public_url is loopback, whatever its scheme, so the wizard's caution asks for a public_url other machines can reach, and comments and docs name the loopback rule. --- apps/web/DESIGN.md | 9 +++++---- apps/web/src/features/sandbox/SandboxSetupWizard.tsx | 4 ++-- apps/web/src/lib/locale-strings.ts | 2 +- apps/web/src/lib/sandbox-labels.ts | 2 +- docs/web/protocol-coverage.md | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 7901c2d0f..f24d5aef9 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -632,10 +632,11 @@ 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, 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 without an -HTTPS `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. +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 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 diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index ab4152752..be3b3f1d1 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -92,7 +92,7 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal 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 an E2B deployment without an HTTPS public_url. + // 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; @@ -231,7 +231,7 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal
{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 an HTTPS public_url in config.json.")} : null} + {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}
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 686da8057..43cf070c5 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -299,7 +299,7 @@ export const chinese = { "Config file": "配置文件", "Then run": "然后运行", "Copy path": "复制路径", - "Only the Core machine can reach this address: nodes on other machines and E2B sandboxes can't. Set an HTTPS public_url in config.json.": "只有 Core 所在的机器能访问这个地址,其他机器上的节点和 E2B 沙箱都无法访问。请在 config.json 中设置 HTTPS 的 public_url。", + "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 地址上,需要移除后重新添加。", diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 078f279ba..8359449e9 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -27,7 +27,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { /** * Core's own reason when it rejects a deployment configuration it cannot serve, - * such as E2B without an HTTPS public_url; null for any other failure. Nothing + * 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 { diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 27275dfba..4991ee5b5 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -119,7 +119,7 @@ the sandbox deployment. | Operation | Route | Console use | | --- | --- | --- | -| 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` (such as E2B without an HTTPS `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 | +| 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. 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 | From b6aa8dea0482f804ebef207e4124cbfde0e4a247 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:52:10 +0000 Subject: [PATCH 14/18] Hold each System section's place while it loads, announced once Installation and Sandboxes show facts-only placeholders and Startup settings a table placeholder, all silent; one status says the page is loading while either first read is out. TableSkeleton stays silent without a label. --- apps/web/src/components/Skeleton.tsx | 6 +++-- apps/web/src/features/system/SystemPage.tsx | 25 +++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) 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/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index 0174e3fb0..dcff96a80 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { CopyableId } from "../../components/list-ui"; -import { DetailSkeleton } from "../../components/Skeleton"; +import { TableSkeleton } from "../../components/Skeleton"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { formatBytes, formatPeriod } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; @@ -19,6 +19,20 @@ 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 (
@@ -59,7 +73,7 @@ export function SystemPage() { if (!data) { body = deployment.isError && !deployment.isFetching ? - : ; + : ; } else if (!data.provider) { body = ( ) : 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 (
@@ -130,9 +146,10 @@ export function SystemPage() { actions={} /> + {reading ?

{t("loading")}

: null} {facts} {body} - {about ? : null} + {about ? : installation.isError ? null : }
); From 3e9217dd24f0c4d9ddad8f9a2b43d88a5be9c5dd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:52:41 +0000 Subject: [PATCH 15/18] Follow Core's deployment write order and counts in the fixture Setup answers 200, a core_url member is rejected before any state check, and hosted_sandboxes is the deployment's allocations plus pending for every provider. The setup test waits for Add node and the recorded write instead of the Runtime text, and the stale-node test proves removal alone clears the warning. --- apps/web/e2e/fixture-console.mjs | 10 +++++----- apps/web/e2e/public-url.spec.ts | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index af7c3c11c..b36b6b7aa 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -60,10 +60,10 @@ function installation() { secret("database_url", ["core"]), ], }, - // As Core counts them: the allocations held on the nodes, and the unrevoked executor credentials. + // 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: nodes.length ? state.allocations.length : 0, + 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, }, }; @@ -288,10 +288,10 @@ async function sandboxRoute(request, response, path) { if (path === "/deployment" && (request.method === "POST" || request.method === "PUT")) { const input = await body(request); 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"); - // As Core: 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"); 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. @@ -305,7 +305,7 @@ async function sandboxRoute(request, response, path) { ...(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, initialize ? 201 : 200, state.deployment); + return send(response, 200, state.deployment); } if (path === "/deployment") { 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 }); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index da4514963..7ef359f2d 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { expectManagementBoundary, openConsole, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -15,7 +15,9 @@ test("sets up sandboxes with Core's address read-only, never sending it", async 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(); - await expect(page.getByText("c0ffee000000")).toBeVisible(); + // 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); }); @@ -61,7 +63,6 @@ test("warns on Nodes about a node bound to an old Core address until it is remov 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 page.getByRole("button", { name: "Refresh sandbox state" }).click(); await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01"); await expect(warning).toHaveCount(0); }); From 72b47437c9376ae8db6028f42f3e0cc7c8df1b2f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:54:05 +0000 Subject: [PATCH 16/18] Polish the stale-node list, copy labels and SDK pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale node names join as a list in the current language. The samples' copy buttons read "Copy the Shell sample" / "复制示例:终端". A unit test ties the Python sample's openai pin to contracts/agents-api/upstream.json, and PRODUCT.md names the how-to-call card on the first-run screen. --- apps/web/src/features/api-keys/HowToCall.test.ts | 10 ++++++++++ apps/web/src/features/sandbox/SandboxManagerView.tsx | 2 +- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/features/api-keys/HowToCall.test.ts 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/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 2d9c99a5d..10a99f8ff 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -267,7 +267,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig {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: staleNodes.join(", ") })}

: null} + : 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/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 06dbe3b5a..92350442c 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -96,7 +96,7 @@ export const keys = { howToCall: { title: "How to call", shell: "Shell", - copy: "Copy {{label}}", + 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.", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 336436313..7ec8ccd9c 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -98,7 +98,7 @@ export const keys: TranslationShape = { howToCall: { title: "如何调用", shell: "终端", - copy: "复制 {{label}}", + copy: "复制示例:{{label}}", copyFailed: "请选中文本后手动复制。", loading: "正在读取 API 地址", failed: "无法读取 API 地址。", From 30cbf3850dfd0b8be0a189221fddc5dd4990bd56 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 20:13:35 +0000 Subject: [PATCH 17/18] Match Core's current E2B public URL message in the fixture ErrSandboxPublicURLUnreachable no longer ends with "then save again". --- apps/web/e2e/fixture-console.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b36b6b7aa..fef5dcd09 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -295,7 +295,7 @@ async function sandboxRoute(request, response, path) { 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), then save again.", "sandbox_configuration_error"); + 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 = { From 697c6a9192669ec77bef49d88e4396fac89666fc Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 26 Sep 2026 04:13:58 +0800 Subject: [PATCH 18/18] Show a saved Agent's model provider in the console (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show a saved Agent's model provider and a dash for a missing harness An Agent's detail page now has a Model provider section when the Agent saved x_agents_core.model_provider: its protocol (Anthropic Messages or OpenAI Responses), base URL, whether an API key is configured, and the context window and max output tokens when Core reports them. The read view has no key and the page never shows one. A saved Agent can have a provider and no harness, so the Agents list and the detail show "—" for a missing harness instead of "Core default". The e2e fixture adds two Production Agents, one with only a provider and one with a harness and a provider, appended after the seeded data so the rest of the demo is unchanged. One acceptance test checks that the project's Agents load, the provider-only Agent's harness reads "—", its provider section shows every field, and no key appears. * Mention the Agent page's model provider in the Web protocol coverage * Show Core default again for an Agent without a saved harness An Agent that saved no harness runs on the deployment's default harness, so "Core default" is accurate, and it matches how the page shows unset generation settings. The acceptance test now expects it for the provider-only Agent. --- apps/web/PRODUCT.md | 4 +++- apps/web/e2e/data/agents.mjs | 24 +++++++++++++++++-- apps/web/e2e/data/routes.mjs | 8 ++++++- apps/web/e2e/resources.spec.ts | 16 +++++++++++++ apps/web/src/features/agents/AgentCatalog.css | 5 ++++ apps/web/src/features/agents/AgentsPage.tsx | 19 ++++++++++++++- apps/web/src/i18n/locales/en/agents.ts | 11 +++++++++ apps/web/src/i18n/locales/zh-CN/agents.ts | 11 +++++++++ docs/web/protocol-coverage.md | 2 +- 9 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index ec979796d..d2838c838 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -122,7 +122,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/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/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/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/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/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/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 4fc7c1c20..166607a12 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -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) |