diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts
index 78000a33..1050c42d 100644
--- a/apps/web/e2e/console.ts
+++ b/apps/web/e2e/console.ts
@@ -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 = {}) {
diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs
index a3de5caf..eb693e42 100644
--- a/apps/web/e2e/fixture-console.mjs
+++ b/apps/web/e2e/fixture-console.mjs
@@ -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";
@@ -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 }),
@@ -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.
@@ -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",
diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts
index bc5ea436..5c762775 100644
--- a/apps/web/e2e/nodes.spec.ts
+++ b/apps/web/e2e/nodes.spec.ts
@@ -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();
@@ -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/ });
diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx
new file mode 100644
index 00000000..15c3360b
--- /dev/null
+++ b/apps/web/src/features/sandbox/NodeCleanupDialog.test.tsx
@@ -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(
{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."))}
+ : 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."))}{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}
{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.")}
: null} {!available ?{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.")}
: !enrollment && blocker ? blocker.failed ?{blocker.text}
diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css index 27b0ad8c..b7cc309f 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.css +++ b/apps/web/src/features/sandbox/SandboxManagerView.css @@ -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; } diff --git a/apps/web/src/features/sandbox/core-origin.test.ts b/apps/web/src/features/sandbox/core-origin.test.ts index d6806825..c126cba6 100644 --- a/apps/web/src/features/sandbox/core-origin.test.ts +++ b/apps/web/src/features/sandbox/core-origin.test.ts @@ -1,5 +1,6 @@ +import type { CoreInstallationSetting } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; -import { httpsOrigin, nodeSourceUrl } from "./core-origin"; +import { allowsInsecureOrigin, httpsOrigin, nodeSourceUrl } from "./core-origin"; describe("HTTPS origin", () => { it.each([ @@ -17,4 +18,30 @@ describe("node command source", () => { expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull(); expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false })).toBeNull(); }); + it("takes a non-loopback HTTP public URL only when allow_insecure_origin is on", () => { + expect(nodeSourceUrl({ public_url: "http://10.0.0.5:8080", local_only: false }, true)).toBe("http://10.0.0.5:8080"); + // The switch never makes a loopback address reachable from another machine. + expect(nodeSourceUrl({ public_url: "http://127.0.0.1:8091", local_only: true }, true)).toBeNull(); + // A path, credentials or a query are not an origin, switch or not. + expect(nodeSourceUrl({ public_url: "http://10.0.0.5:8080/v1", local_only: false }, true)).toBeNull(); + expect(nodeSourceUrl({ public_url: "http://user:secret@10.0.0.5:8080", local_only: false }, true)).toBeNull(); + }); +}); + +describe("the allow_insecure_origin switch", () => { + const setting = (key: string, value: unknown): CoreInstallationSetting => ({ key, value, default: false, changeable: true, sensitive: false, restarts: ["core"] }); + const installation = (settings: CoreInstallationSetting[] | null) => ({ + configuration: settings === null ? null : { path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-01-01T00:00:00Z", settings }, + }); + it("reads Core's settings snapshot", () => { + expect(allowsInsecureOrigin(installation([setting("allow_insecure_origin", true)]))).toBe(true); + expect(allowsInsecureOrigin(installation([setting("allow_insecure_origin", false)]))).toBe(false); + }); + it("is off when the snapshot lacks it or there is none", () => { + expect(allowsInsecureOrigin(installation([setting("public_url", "https://core.example")]))).toBe(false); + // Only a literal true turns it on; any other value reads as off. + expect(allowsInsecureOrigin(installation([setting("allow_insecure_origin", "true")]))).toBe(false); + expect(allowsInsecureOrigin(installation(null))).toBe(false); + expect(allowsInsecureOrigin(undefined)).toBe(false); + }); }); diff --git a/apps/web/src/features/sandbox/core-origin.ts b/apps/web/src/features/sandbox/core-origin.ts index 091a2705..12c2a789 100644 --- a/apps/web/src/features/sandbox/core-origin.ts +++ b/apps/web/src/features/sandbox/core-origin.ts @@ -3,14 +3,22 @@ import type { CoreInstallation } from "@oac/agents-client"; import { isValidDirectCoreBaseUrl } from "../../lib/connection"; /** - * The value itself when it is an HTTPS origin: no path, query, fragment or - * credentials; a single trailing slash is dropped. Otherwise null. It is kept - * as written, not normalized, so an explicit port such as :443 stays exactly - * as Core reports it. + * The value itself when it is an origin the node commands may use directly: no + * path, query, fragment or credentials; a single trailing slash is dropped. It + * is kept as written, not normalized, so an explicit port such as :443 stays + * exactly as Core reports it. HTTPS always qualifies; plain HTTP only on a + * loopback host, or on any host when `allowInsecure` is set (a development + * installation with `allow_insecure_origin`). */ -export function httpsOrigin(value: string): string | null { +function origin(value: string, allowInsecure: boolean): string | null { const candidate = value.trim().replace(/\/$/, ""); - return /^https:\/\/[^/?#\\\s@]+$/i.test(candidate) && isValidDirectCoreBaseUrl(candidate) ? candidate : null; + const pattern = allowInsecure ? /^https?:\/\/[^/?#\\\s@]+$/i : /^https:\/\/[^/?#\\\s@]+$/i; + return pattern.test(candidate) && isValidDirectCoreBaseUrl(candidate, allowInsecure) ? candidate : null; +} + +/** The HTTPS origin form, the default the node commands require. */ +export function httpsOrigin(value: string): string | null { + return origin(value, false); } /** @@ -18,9 +26,19 @@ export function httpsOrigin(value: string): string | null { * pass it: the installation's public URL, whose reverse proxy sends * `/node-install/*` to this console. Unlike the browser's address, it is the * same from every machine. Null when other machines can't use it: loopback - * (`local_only`), missing, or not an HTTPS origin. + * (`local_only`), missing, or not an HTTPS origin unless `allowInsecure` is set. */ -export function nodeSourceUrl(installation: Pick