Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,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, `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), `credentials: "none"` a Core without a
* (loopback: only the Core machine reaches the API, and E2B is rejected), "stale" (public,
* with a node enrolled with an earlier address), "http" (a non-loopback HTTP address with
* allow_insecure_origin off) or "insecure" (the same address with allow_insecure_origin on),
* `credentials: "none"` a Core without a
* credential encryption key, which cannot store a model provider's key, and
* `installers: "none"` a console without its node installation payload, so it serves neither
* the node nor the self-hosted installer. `nodeArtifacts` lists the providers the console has
* node files for, both by default; as in the console, microsandbox needs Docker's files too.
*/
export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] }
export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale" | "http" | "insecure"; credentials?: "none"; installers?: "none"; nodeArtifacts?: ("docker" | "microsandbox")[] }

/** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */
export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) {
Expand Down
15 changes: 11 additions & 4 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@ const manifest = { platform: "linux/amd64", source_commit: release.source_commit
/**
* 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.
* "stale": public, with a node still enrolled with an earlier address;
* "http": a non-loopback HTTP address with allow_insecure_origin off;
* "insecure": the same address with allow_insecure_origin on.
*/
const PUBLIC_URL = "https://core.example.com";
const LOCAL_URL = "http://127.0.0.1:8091";
/** A non-loopback HTTP address a development installation with allow_insecure_origin may use. */
const INSECURE_URL = "http://10.0.0.5:8080";
/** 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);
/** "insecure" and "http" share the plain-HTTP address; only "insecure" turns the switch on. */
const httpAddress = () => state.installation === "insecure" || state.installation === "http";
const publicUrl = () => (state.installation === "local" ? LOCAL_URL : httpAddress() ? INSECURE_URL : PUBLIC_URL);
/** The digest the console reports for its self-hosted executor installer; the same value as in monitoring.spec.ts. */
/** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */
const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f";
Expand All @@ -54,6 +60,7 @@ function installation() {
path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-09-24T09:30:00Z",
settings: [
setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]),
setting("allow_insecure_origin", state.installation === "insecure", false, ["core"]),
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/oac", "/var/lib/oac", [], { changeable: false }),
Expand Down Expand Up @@ -97,7 +104,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d
// Self-hosted Sessions get their remote_url from public_url, as in Core.
const screenshots = process.env.OAC_WEB_SCREENSHOT_DEMO === "1";
const now = Math.floor(Date.now() / 1000);
const base = (screenshots ? buildScreenshotDemo : buildDemo)(now, address === "local" ? LOCAL_URL : PUBLIC_URL);
const base = (screenshots ? buildScreenshotDemo : buildDemo)(now, address === "local" ? LOCAL_URL : address === "insecure" || address === "http" ? INSECURE_URL : PUBLIC_URL);
const resources = buildResources(now, base.agents, base.sessions);
const admin = buildAdmin(now, base, resources);
// A fresh install: no project, Session or Runtime yet; Getting started leads.
Expand All @@ -111,7 +118,7 @@ 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".
// How config.json's public_url is set: "public", "local", "stale", "http" or "insecure".
installation: address,
// "none": Core has no credential encryption key, so it cannot store a provider's key.
credentialKey: credentials !== "none",
Expand Down
40 changes: 40 additions & 0 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,34 @@ test("issues no command before the installation is read, for a loopback public U
await expect(add.getByRole("button", { name: "Generate command" })).toBeVisible();
});

test("offers a plaintext HTTP node command with a warning only when allow_insecure_origin is on", async ({ page, request }) => {
// The switch is off by default: a non-loopback HTTP public URL still blocks Add node.
await openConsole(page, request, "nodes", { installation: "http" });
await page.getByRole("button", { name: "Add node" }).click();
const blocked = page.getByRole("dialog", { name: "Add node" });
await expect(blocked.getByRole("status")).toHaveText("Set a public HTTPS address before adding nodes.");
await expect(blocked.getByRole("button", { name: "Generate command" })).toHaveCount(0);
await expect(blocked.getByText(/Plaintext HTTP/)).toHaveCount(0);
// Close before reopening: the next openConsole keeps the same #nodes hash, so the page does
// not reload and a leftover overlay would intercept the next click.
await blocked.getByRole("button", { name: "Close dialog" }).click();
await expect(blocked).toBeHidden();

// With the switch on, the command downloads from and names the plain-HTTP public URL, and the dialog warns.
await openConsole(page, request, "nodes", { installation: "insecure" });
await page.getByRole("button", { name: "Add node" }).click();
const add = page.getByRole("dialog", { name: "Add node" });
await expect(add.getByRole("alert")).toContainText("Plaintext HTTP");
await expect(add.getByText("Reaches http://10.0.0.5:8080, as do its sandboxes")).toBeVisible();
await add.getByLabel("Sandboxes at once").fill("2");
const issued = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens"));
await add.getByRole("button", { name: "Generate command" }).click();
await issued;
const field = add.getByLabel("One-time enrollment command", { exact: true });
await expect(field).toHaveValue(/curl [^\n]* 'http:\/\/10\.0\.0\.5:8080\/node-install\/node-install\.pyz' /);
await expect(field).toHaveValue(/ --source-url 'http:\/\/10\.0\.0\.5:8080' --core-url 'http:\/\/10\.0\.0\.5:8080' /);
});

test("removes a node after confirmation", async ({ page, request }) => {
await openConsole(page, request, "nodes");
await page.getByRole("button", { name: "Remove edge-03" }).click();
Expand All @@ -165,6 +193,18 @@ test("removes a node after confirmation", async ({ page, request }) => {
await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeFocused();
});

test("gives the host's uninstall command over a plain-HTTP public URL when allow_insecure_origin is on", async ({ page, request }) => {
await openConsole(page, request, "nodes", { installation: "insecure" });
await page.getByRole("button", { name: "Remove edge-03" }).click();
await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click();
// The node Add node enrolled over http://IP:8080 is cleaned up over the same address, not blocked on HTTPS.
const cleanup = page.getByRole("dialog", { name: "Clean up the host" });
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/'http:\/\/10\.0\.0\.5:8080\/node-install\/node-install\.pyz'/);
await expect(cleanup.getByText("An uninstall command needs an HTTPS public URL", { exact: false })).toHaveCount(0);
await cleanup.getByRole("button", { name: "Done" }).click();
await expect(cleanup).toBeHidden();
});

test("marks a node on an old Core address in its row, beside each node's limit", async ({ page, request }) => {
await openConsole(page, request, "nodes", { installation: "stale" });
const row = page.getByRole("row", { name: /core-01/ });
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CoreInstallation } from "@oac/agents-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { installationQuery } from "../../lib/installation";
import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog";

const cleanup: NodeCleanup = {
name: "edge-01", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f",
scriptDigest: "a".repeat(64), provider: "docker", oldAddress: null,
};

/** The installation Add node used: `allowInsecure` undefined means the snapshot predates the setting. */
function installation(allowInsecure: boolean | undefined, publicUrl: string | null): CoreInstallation {
return {
object: "core.installation", installation_id: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f",
public_url: publicUrl, api_base_url: publicUrl === null ? null : `${publicUrl}/v1`, local_only: false,
source_commit: "c".repeat(40),
configuration: {
path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-01-01T00:00:00Z",
settings: allowInsecure === undefined ? [] : [{ key: "allow_insecure_origin", value: allowInsecure, default: false, changeable: true, sensitive: false, restarts: ["core"] }],
},
address_bindings: { nodes: 1, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 0 },
};
}

function render(data: CoreInstallation): string {
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } });
cache.setQueryData(installationQuery.queryKey, data);
const html = renderToStaticMarkup(<QueryClientProvider client={cache}><NodeCleanupDialog cleanup={cleanup} open onClose={() => {}} /></QueryClientProvider>);
cache.clear();
return html;
}

describe("the host uninstall command under allow_insecure_origin", () => {
it("generates the command for a non-loopback plain-HTTP public URL when the switch is on", () => {
const html = render(installation(true, "http://10.0.0.5:8080"));
expect(html).toContain("http://10.0.0.5:8080/node-install/node-install.pyz");
expect(html).toContain("--uninstall --installation-id");
});
it("keeps the HTTPS requirement and issues no command when the switch is off", () => {
const html = render(installation(false, "http://10.0.0.5:8080"));
expect(html).not.toContain("node-install.pyz");
expect(html).toContain("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.");
});
it("keeps the HTTPS requirement when the snapshot predates the setting", () => {
const html = render(installation(undefined, "http://10.0.0.5:8080"));
expect(html).not.toContain("node-install.pyz");
expect(html).toContain("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none.");
});
it("drops only the HTTPS wording when the switch is on but no public URL is usable", () => {
const html = render(installation(true, null));
expect(html).toContain("An uninstall command needs a public URL that other machines can reach, and this installation has none.");
expect(html).not.toContain("An uninstall command needs an HTTPS public URL");
});
});
13 changes: 9 additions & 4 deletions apps/web/src/features/sandbox/NodeCleanupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";

import { Modal } from "../../components/Modal";
import { installationQuery } from "../../lib/installation";
import { nodeSourceUrl } from "./core-origin";
import { allowsInsecureOrigin, nodeSourceUrl } from "./core-origin";
import { nodeUninstallCommand } from "./enrollment-command";
import { CommandBlock } from "./node-commands";

Expand All @@ -26,14 +26,17 @@ export interface NodeCleanup {
* images. Like Add node's, the command downloads from the installation's public
* URL, which the dialog reads (again, if it is not at hand): until it is read, if
* the read fails (with Try again), or while other machines can't use it, the
* dialog says so in place of the command. It never opens empty.
* dialog says so in place of the command. It never opens empty. With the
* installation's `allow_insecure_origin` switch on, a non-loopback plain-HTTP
* public URL is accepted, as it is for Add node.
*/
export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) {
const { t, i18n } = useTranslation("sandbox");
// Sentences run on with a space in English and without one in Chinese.
const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " ");
const installation = useQuery({ ...installationQuery, enabled: cleanup !== null });
const sourceUrl = installation.data ? nodeSourceUrl(installation.data) : null;
const allowInsecure = allowsInsecureOrigin(installation.data);
const sourceUrl = installation.data ? nodeSourceUrl(installation.data, allowInsecure) : null;
const command = (force = false) => cleanup && sourceUrl
? nodeUninstallCommand({ sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, force }) : "";
const stays = cleanup ? t("{{name}} is removed from Core, but its service and files stay on the host.", { name: cleanup.name }) : "";
Expand All @@ -45,7 +48,9 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle
</div> : cleanup && !sourceUrl ? <div className="sandbox-add-node form-stack">
<p>{join(stays, installation.data?.local_only && installation.data.public_url
? t("Other machines can't reach this installation's public URL, {{url}}, so no uninstall command can be given.", { url: installation.data.public_url })
: t("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."))}</p>
: allowInsecure
? t("An uninstall command needs a public URL that other machines can reach, and this installation has none.")
: t("An uninstall command needs an HTTPS public URL that other machines can reach, and this installation has none."))}</p>
</div> : cleanup ? <div className="sandbox-add-node form-stack">
<p>{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}</p>
<CommandBlock key={command()} value={command()} label={t("Uninstall command")} autoFocus />
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/features/sandbox/NodeEnrollment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic";
import { sandboxRequestError } from "../../lib/sandbox-labels";
import { checklistOpenFor, modelStep, nextStepAfterNode } from "../overview/getting-started";
import { harnessesQuery } from "../system/harness-queries";
import { nodeSourceUrl } from "./core-origin";
import { allowsInsecureOrigin, nodeSourceUrl } from "./core-origin";
import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config";
import { nodeInstallCommand, nodeLogCommand } from "./enrollment-command";
import { CommandBlock, CopyCommand, HostRequirements } from "./node-commands";
Expand Down Expand Up @@ -43,7 +43,8 @@ const DEFAULT_RETAINED = "8";
* sudo (or directly as root), which installs the node as a system service.
* The log hint names that system service. No command is issued until the installation
* is read: one whose public URL other machines can't use (loopback, as
* `local_only` says, or not HTTPS), an unreadable one, or a console that
* `local_only` says, or not HTTPS unless the installation's
* `allow_insecure_origin` switch is on), an unreadable one, or a console that
* reports no node files for the deployment's provider (`node_artifacts`) says
* so instead. Each opening, and each return to the window while open, reads
* the installation and the console again, so a fix on the Core host shows
Expand Down Expand Up @@ -89,7 +90,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open,
const request = useRef<AbortController | null>(null);
// Nodes download from, and reach Core at, the public URL; the browser's address may be a tunnel or loopback.
// The deployment's core_url is the same address, but the installation is read again on each opening, so a fix shows at once.
const publicUrl = installation.data ? nodeSourceUrl(installation.data) : null;
const allowInsecure = allowsInsecureOrigin(installation.data);
const publicUrl = installation.data ? nodeSourceUrl(installation.data, allowInsecure) : null;
// The node commands use plain HTTP only when the development switch is on; say so where they appear.
const insecure = allowInsecure && publicUrl !== null && publicUrl.startsWith("http://");
const available = consoleConfig.node_installer;
const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null;
const backend = provider === "microsandbox" ? "microsandbox" : "Docker";
Expand Down Expand Up @@ -265,6 +269,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open,
: sandboxDiagnosticMessage(progress.problem, locale);
return createPortal(<Modal open={open} title={t("Add node")} onClose={close} footer={footer}>
<div className="sandbox-add-node form-stack">
{insecure ? <p className="sandbox-insecure-origin" role="alert">{t("Plaintext HTTP: allow_insecure_origin is on, so the enrollment token and the node's credentials travel unencrypted. Use this only on a trusted network.")}</p> : null}
{!available ? <p role="status">{t("This console serves no node installer. For a console deployed by hand, point OAC_WEB_NODE_PAYLOAD_DIR at the distribution's node payload and restart it.")}</p>
: !enrollment && blocker ? blocker.failed
? <p role="alert">{blocker.text} <button className="text-action" type="button" disabled={installation.isFetching} onClick={() => void installation.refetch()}>{t("Try again")}</button></p>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/features/sandbox/SandboxManagerView.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
.sandbox-summary dt { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; }
.sandbox-summary dd { margin: 0; color: var(--ink); font-size: 13px; overflow-wrap: anywhere; }
.sandbox-error { color: var(--danger) !important; }
/* A development installation with allow_insecure_origin: the command is plain HTTP. */
.sandbox-insecure-origin {
padding: 10px 12px;
color: var(--fg) !important;
font-size: 12.5px;
background: color-mix(in srgb, var(--warning) 9%, var(--surface));
border: 1px solid color-mix(in srgb, var(--warning) 26%, var(--line));
border-radius: 8px;
}
.sandbox-empty { display: grid; justify-items: center; gap: 12px; text-align: center; border: 1px dashed var(--line); border-radius: 12px; padding: 56px 24px; color: var(--fg-muted); }
.sandbox-empty h3 { color: var(--fg); font-size: 16px; margin: 0; font-weight: 500; }
.sandbox-empty p { max-width: 320px; font-size: 13px; }
Expand Down
Loading
Loading