Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/web/e2e/fixture-sandbox.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let provider = "docker";
let diagnostic = "";
let coreUrl = "";
export function resetSandboxFixture() {
nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = "";
nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = "https://core.example";
}
resetSandboxFixture();
export function handleSandboxFixture(request, response, url, sendJson, sendError) {
Expand Down
71 changes: 71 additions & 0 deletions apps/web/e2e/sandbox-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ test("add opens one command, copy works, existing hosts do not imply connection,
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openManager(page);
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
const dialog = page.getByRole("dialog", { name: "Add node" });
const command = dialog.getByLabel("One-time enrollment command");
await expect(command).toHaveValue(/fixture-once-token/);
Expand All @@ -67,6 +72,11 @@ test("unavailable installer shows compact guidance and never creates an enrollme
await page.route("**/console/config", (route) => route.fulfill({ json: { sandbox_admin: true, node_installer: false } }));
await openManager(page);
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("Node installation is unavailable");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
Expand Down Expand Up @@ -107,6 +117,8 @@ test("Chinese actions, diagnostics, and enrollment are translated and language p
await page.locator(".sandbox-topology-node").first().click();
await expect(page.getByRole("region", { name: "沙箱资源分配" }).first()).toContainText("沙箱资源缺失");
await page.getByRole("button", { name: "添加节点", exact: true }).click();
await page.getByLabel("新节点可访问的控制台地址").fill("https://console.example");
await page.getByRole("button", { name: "生成注册命令", exact: true }).click();
await expect(page.getByLabel("一次性注册命令")).toHaveValue(/fixture-once-token/);
await expect(page.getByRole("dialog")).toContainText("等待节点连接");
await page.keyboard.press("Escape");
Expand All @@ -128,12 +140,22 @@ test("an uncertain write is never retried; closing discards a late token and all
await route.fulfill({ json: { token: attempts === 1 ? "stale-token" : "fresh-token", expires_at: new Date(Date.now() + 600000).toISOString() } }).catch(() => {});
});
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
await expect.poll(() => attempts).toBe(1);
await page.keyboard.press("Escape");
release();
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(attempts).toBe(1);
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fresh-token/);
expect(attempts).toBe(2);
});
Expand All @@ -148,6 +170,11 @@ test("a connected node remains successful after its enrollment token expires", a
});
await openManager(page);
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
const dialog = page.getByRole("dialog", { name: "Add node" });
await expect(dialog.getByLabel("One-time enrollment command")).toHaveValue(/short-lived-token/);
await request.post(`${fixture}/__fixture/sandbox-add-node`);
Expand All @@ -171,6 +198,11 @@ test("expired commands and failed writes require an explicit retry", async ({ pa
return attempts === 1 ? route.fulfill({ json: { token: "expired-token", expires_at: "2020-01-01T00:00:00Z" } }) : route.fulfill({ status: 503, json: { error: { message: "Unavailable" } } });
});
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
await expect(page.getByRole("dialog")).toContainText("Command expired");
await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0);
expect(attempts).toBe(1);
Expand Down Expand Up @@ -220,6 +252,11 @@ for (const width of [1280, 1440]) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ animations: "disabled", path: testInfo.outputPath(`nodes-${width}-${theme}.png`) });
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/);
const copyButton = page.getByRole("button", { name: "Copy node command" });
await expect(copyButton).toBeInViewport();
Expand Down Expand Up @@ -302,3 +339,37 @@ for (const theme of ["light", "dark"]) {
await page.locator("#sandbox-selected-node").screenshot({ path: testInfo.outputPath(`node-details-zh-${theme}.png`), animations: "disabled" });
});
}

test("SSH tunnel enrollment requires a reachable console address before issuing a token", async ({ page, request }) => {
await openManager(page);
await page.getByRole("button", { name: "Add node", exact: true }).click();
const dialog = page.getByRole("dialog");
const source = dialog.getByLabel("Console address reachable from the new node");
const generate = dialog.getByRole("button", { name: "Generate enrollment command", exact: true });
await expect(source).toHaveValue("");
for (const invalid of ["http://127.0.0.1:14173", "https://localhost", "https://console.example/v1", "https://user:secret@console.example"]) {
await source.fill(invalid);
await expect(generate).toBeDisabled();
}
const before = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls;
expect(before.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0);
await source.fill("https://console.example");
await generate.click();
const command = dialog.getByLabel("One-time enrollment command");
await expect(command).toHaveValue(/--source-url 'https:\/\/console.example'/);
await expect(command).toHaveValue(/--core-url 'https:\/\/core.example'/);
await expect(command).not.toHaveValue(/127\.0\.0\.1/);
});

test("a loopback deployment origin cannot issue a remote-node command", async ({ page, request }) => {
await page.route("**/core/v1/sandbox/deployment", async (route) => {
const deployment = await (await route.fetch()).json();
await route.fulfill({ json: { ...deployment, core_url: "http://127.0.0.1:8080" } });
});
await openManager(page);
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("deployment needs an HTTPS Core address");
await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0);
const calls = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls;
expect(calls.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0);
});
17 changes: 16 additions & 1 deletion apps/web/e2e/sandbox-setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ test("bundled console needs no extra admin key and provides one install command
await page.getByLabel("Sandbox provider").selectOption("docker");
await page.getByRole("button", { name: "Initialize sandbox deployment" }).click();
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
const command = page.getByLabel("One-time enrollment command");
await expect(command).toHaveValue(/fixture-once-token/);
await expect(command).toHaveValue(/\/node-install\/node-install.pyz/);
Expand Down Expand Up @@ -68,6 +73,11 @@ for (const provider of ["docker", "microsandbox"]) {
await expect(page.getByLabel("Sandbox provider")).toHaveCount(0);
await expect(page.getByText("No nodes registered. Add a node to provide hosted capacity.")).toBeVisible();
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/--core-url 'https:\/\/core.example'/);
expect(await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }))).not.toMatch(/fixture-admin-key|fixture-once-token/);
await request.post(`${fixture}/__fixture/sandbox-add-node`);
Expand Down Expand Up @@ -167,6 +177,11 @@ for (const operation of ["setup", "enrollment"] as const) {
await request.post(setupUrl, { data: { provider: "docker", core_url: "https://core.example" } });
await page.getByRole("button", { name: "Refresh sandbox state" }).click();
await page.getByRole("button", { name: "Add node", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
if (await page.getByLabel("Console address reachable from the new node").isVisible()) {
await page.getByLabel("Console address reachable from the new node").fill("https://console.example");
await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click();
}
}
await expect.poll(() => page.evaluate(() => typeof (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse)).toBe("function");
await page.evaluate(() => { location.hash = "system"; });
Expand All @@ -193,7 +208,7 @@ test("unpaired or unavailable consoles show setup guidance without admin credent
await page.route("**/console/config", (route) => route.fulfill(body ? { contentType: "application/json", body: JSON.stringify(body) } : { status: 404, body: "Not found" }));
await page.reload();
await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Sandbox administration is not configured");
await expect(page.getByRole("alert")).toContainText(body ? "Sandbox administration is not configured" : "Console access could not be checked");
await expect(page.getByLabel("Deployment admin key")).toHaveCount(0);
expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0);
}
Expand Down
16 changes: 11 additions & 5 deletions apps/web/src/features/sandbox/NodeEnrollment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { SandboxAdminClient, SandboxDeployment, SandboxNode } from "@agents
import { useTranslation } from "react-i18next";
import { Modal } from "../../components/Modal";
import { sandboxRequestError } from "../../lib/sandbox-labels";
import { sandboxCoreOrigin } from "./core-origin";
import { sandboxSetupOrigin } from "./core-origin";
import type { SandboxConsoleConfig } from "./console-config";
import { nodeInstallCommand } from "./enrollment-command";

Expand All @@ -32,8 +32,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab
const request = useRef<AbortController | null>(null);
const knownIds = useRef(new Set<string>());
const revealedId = useRef<string | null>(null);
const sourceUrl = sandboxCoreOrigin(window.location.origin);
const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin);
const [sourceDraft, setSourceDraft] = useState(() => sandboxSetupOrigin(window.location.origin) ?? "");
const sourceUrl = sandboxSetupOrigin(sourceDraft);
const coreUrl = sandboxSetupOrigin(deployment.core_url || window.location.origin);
const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl);
const expired = Boolean(enrollment && new Date(enrollment.expires_at).getTime() <= now);
const connected = fresh && nodes.find((node) => !knownIds.current.has(node.id) && node.online && node.provider_ready);
Expand Down Expand Up @@ -62,7 +63,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab
setCopied(false); setCopyFailed(false);
}
async function generate() {
if (request.current || !available) return;
if (request.current || !available || disabled || !fresh) return;
generation.current++;
const controller = new AbortController(); request.current = controller;
knownIds.current = new Set(nodes.map((node) => node.id));
Expand All @@ -87,8 +88,13 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab
<button type="button" className="button primary" disabled={disabled} onClick={() => { setOpen(true); void generate(); }}><Plus size={16} />{t("Add node")}</button>
{createPortal(<Modal open={open} title={t("Add node")} onClose={close}>
<div className="sandbox-add-node form-stack">
{!available ? <p role="status">{t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}</p> : <>
{!consoleConfig.node_installer || !coreUrl ? <p role="status">{t(!consoleConfig.node_installer ? "Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console." : "The deployment needs an HTTPS Core address reachable from nodes and sandbox guests. Ask the deployment administrator to configure it.")}</p> : <>
{!connected ? <p>{t("Run on the host you want to add.")}</p> : null}
{!enrollment && !busy && error === null ? <div className="form-stack">
<label className="field"><span>{t("Console address reachable from the new node")}</span><input type="url" value={sourceDraft} placeholder="https://console.example" onChange={(event) => setSourceDraft(event.target.value)} /></label>
<p>{t("Use the HTTPS address of this console. A localhost address or SSH tunnel on your computer cannot be reached from another machine.")}</p>
<button type="button" className="button primary" disabled={!available || disabled || !fresh} onClick={() => void generate()}>{t("Generate enrollment command")}</button>
</div> : null}
{busy ? <p role="status">{t("Preparing your command…")}</p> : null}
{error !== null ? <><p role="alert" className="sandbox-error">{sandboxRequestError(error, locale)}</p><button className="button outline" type="button" onClick={() => void generate()}>{t("Try again")}</button></> : null}
{enrollment ? <>
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/features/sandbox/SandboxManagerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ function SandboxAccess({ presentation }: { presentation: "manager" | "home" }) {
return () => controller.abort();
}, [revision]);
if (checking) return <p role="status">{t("Connecting to this console's Core…")}</p>;
if (!config?.sandbox_admin) return <div><p role="alert">{t("Sandbox administration is not configured on this console. Ask the deployment administrator to configure access.")}</p><button type="button" className="button outline" onClick={() => setRevision((value) => value + 1)}>{t("Refresh sandbox state")}</button></div>;
if (!config?.sandbox_admin) return <div className="sandbox-content form-stack">
<header className="sandbox-heading"><div><h1>{t("Hosted Sandbox Manager")}</h1><p>{t("Manage hosted execution for this Core deployment.")}</p></div></header>
<div className="sandbox-empty"><Server size={28} strokeWidth={1.25} /><h2>{t(config ? "Machine management needs setup" : "Cannot connect to this console")}</h2>
<p role="alert">{t(config ? "Sandbox administration is not configured on this console. Ask the deployment administrator to configure access." : "Console access could not be checked. Check your connection or sign in again, then refresh.")}</p>
<button type="button" className="button outline" onClick={() => setRevision((value) => value + 1)}><RefreshCw size={15} />{t("Refresh sandbox state")}</button>
</div>
</div>;
return <SandboxManager consoleConfig={config} presentation={presentation} />;
}

Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/locale-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { apiKeyChinese } from "./api-key-strings";
import { firstRunChinese } from "./first-run-strings";
import { consoleAuthChinese } from "./console-auth-strings";
export const chinese = {
"The deployment needs an HTTPS Core address reachable from nodes and sandbox guests. Ask the deployment administrator to configure it.": "部署需要配置节点和沙箱都能访问的 HTTPS Core 地址,请联系部署管理员。",
"Console address reachable from the new node": "新节点可访问的控制台地址",
"Use the HTTPS address of this console. A localhost address or SSH tunnel on your computer cannot be reached from another machine.": "请使用此控制台的 HTTPS 地址。其他机器无法访问你电脑上的 localhost 地址或 SSH 隧道。",
"Generate enrollment command": "生成注册命令",
"Machine management needs setup": "机器管理需要配置",
"Cannot connect to this console": "无法连接此控制台",
"Console access could not be checked. Check your connection or sign in again, then refresh.": "无法确认控制台访问状态。请检查连接或重新登录,然后刷新。",
...apiKeyChinese,
...consoleAuthChinese,
...firstRunChinese,
Expand Down
Loading