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