From d05b91f34ba2451538587ec5bc99fd16d18bb978 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Tue, 27 Oct 2026 18:22:22 +0000 Subject: [PATCH] Edit the sandbox network cap in Settings --- apps/web/e2e/sandbox-lifecycle.spec.ts | 24 +++++++ .../features/sandbox/NetworkSettings.test.tsx | 30 ++++++++ .../src/features/sandbox/NetworkSettings.tsx | 68 +++++++++++++++++++ .../sandbox/SandboxDeploymentPage.tsx | 8 ++- .../features/sandbox/SandboxManagerView.css | 3 + .../src/features/sandbox/network-policy.ts | 32 +++++++++ apps/web/src/i18n/locales/zh-CN/sandbox.ts | 14 ++++ docs/configuration.md | 2 +- docs/zh/configuration.md | 4 +- 9 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/features/sandbox/NetworkSettings.test.tsx create mode 100644 apps/web/src/features/sandbox/NetworkSettings.tsx create mode 100644 apps/web/src/features/sandbox/network-policy.ts diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts index f1894c504..9a0fc2b4f 100644 --- a/apps/web/e2e/sandbox-lifecycle.spec.ts +++ b/apps/web/e2e/sandbox-lifecycle.spec.ts @@ -64,3 +64,27 @@ test("edits deployment idle time without changing generation", async ({ page, re expect((await writes(request)).filter((write) => write === "PATCH /core/v1/sandbox/deployment")).toHaveLength(1); expect(nodeReads).toEqual([]); }); + +test("edits the sandbox network cap for new sandboxes", async ({ page, request }) => { + await openConsole(page, request, "settings?id=sandbox"); + const area = page.getByRole("region", { name: "Network" }); + const domains = area.getByLabel("Extra allowed domains"); + await expect(area.getByRole("switch", { name: "Allow public internet" })).toBeChecked(); + await domains.fill("Git.example.com\n*.example.com"); + await expect(area.getByText("Git.example.com must be lowercase.")).toBeVisible(); + await expect(area.getByText("*.example.com is not a valid host name.")).toBeVisible(); + await expect(area.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await domains.fill("gitlab.example.com"); + await area.getByRole("switch", { name: "Allow public internet" }).uncheck(); + const response = page.waitForResponse((response) => response.request().method() === "PUT" && response.url().endsWith("/sandbox/deployment")); + await area.getByRole("button", { name: "Save", exact: true }).click(); + expect((await response).request().postDataJSON().network).toEqual({ public: false, domains: ["gitlab.example.com"] }); + await expect(page.getByText("New sandboxes will run with this policy")).toBeVisible(); +}); + +test("greys out private domains where the provider cannot reach a private network", async ({ page, request }) => { + await openConsole(page, request, "settings?id=sandbox", { sandbox: "e2b" }); + const area = page.getByRole("region", { name: "Network" }); + await expect(area.getByLabel("Extra allowed domains")).toBeDisabled(); + await expect(area.getByRole("switch", { name: "Allow public internet" })).toBeEnabled(); +}); diff --git a/apps/web/src/features/sandbox/NetworkSettings.test.tsx b/apps/web/src/features/sandbox/NetworkSettings.test.tsx new file mode 100644 index 000000000..2f6a84ca1 --- /dev/null +++ b/apps/web/src/features/sandbox/NetworkSettings.test.tsx @@ -0,0 +1,30 @@ +import type { SandboxDeployment } from "@oac/agents-client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { NetworkForm } from "./NetworkSettings"; +import { domainIssues, parseDomains } from "./network-policy"; + +const deployment = (provider: "microsandbox" | "e2b") => ({ provider, specification: { resources: { cpus: 2, memory_mib: 4096 }, network: { public: true, domains: ["gitlab.example.com"] } } }) as SandboxDeployment; + +describe("network policy", () => { + it("validates domains with the generated rule", () => { + expect(domainIssues(parseDomains("gitlab.example.com\n\n a.b-c.example "))).toEqual([]); + expect(domainIssues(["*.example.com", "-x.com", "Git.example.com", "a.com", "a.com", "a".repeat(254)]).map((issue) => issue.problem)).toEqual(["invalid", "invalid", "uppercase", "duplicate", "too_long"]); + expect(domainIssues(Array.from({ length: 101 }, (_, index) => `h${index}.example.com`))).toEqual([{ problem: "too_many" }]); + }); + + it("enables both controls where the provider supports them", () => { + const html = renderToStaticMarkup( true} />); + expect(html).toContain("Allow public internet"); + expect(html).toContain("gitlab.example.com"); + expect(html).not.toMatch(/]*disabled/); + expect(html).not.toMatch(/]*disabled/); + }); + + it("disables the domain list where the provider cannot reach a private network", () => { + const html = renderToStaticMarkup( true} />); + expect(html).toMatch(/]*disabled/); + expect(html).not.toMatch(/]*disabled/); + expect(html).toContain("cannot reach your private network"); + }); +}); diff --git a/apps/web/src/features/sandbox/NetworkSettings.tsx b/apps/web/src/features/sandbox/NetworkSettings.tsx new file mode 100644 index 000000000..b11dfc51b --- /dev/null +++ b/apps/web/src/features/sandbox/NetworkSettings.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { SandboxDeployment, SandboxSpecification } from "@oac/agents-client"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { HelpTip, Section } from "../../components/console-ui"; +import { useToast } from "../../components/Toast"; +import { sandboxProviderLabel } from "../../lib/sandbox-labels"; +import { domainIssues, maxDomains, networkSupport, parseDomains, type DomainIssue } from "./network-policy"; + +type Network = SandboxSpecification["network"]; + +/** Settings edits the deployment's network cap; it applies to new sandboxes only. */ +export function NetworkSettings({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (network: Network) => Promise }) { + const { t } = useTranslation("sandbox"); + const toast = useToast(); + async function save(network: Network) { + const saved = await onSave(network); + if (saved) toast.show(t("New sandboxes will run with this policy")); + return saved; + } + return ; +} + +export function NetworkForm({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (network: Network) => Promise }) { + const { t, i18n } = useTranslation("sandbox"); + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; + const current = deployment.specification?.network; + const [publicAccess, setPublicAccess] = useState(null); + const [text, setText] = useState(null); + if (!current || !deployment.provider) return null; + const support = networkSupport(deployment.provider); + const provider = sandboxProviderLabel(deployment.provider, locale); + const allowPublic = publicAccess ?? current.public; + const domains = text === null ? current.domains : parseDomains(text); + const issues = domainIssues(domains); + const changed = allowPublic !== current.public || domains.join("\n") !== current.domains.join("\n"); + const message = (issue: DomainIssue) => { + switch (issue.problem) { + case "too_many": return t("Add at most {{count}} domains.", { count: maxDomains }); + case "duplicate": return t("{{domain}} is listed twice.", { domain: issue.domain }); + case "uppercase": return t("{{domain}} must be lowercase.", { domain: issue.domain }); + case "too_long": return t("{{domain}} is too long.", { domain: issue.domain }); + default: return t("{{domain}} is not a valid host name.", { domain: issue.domain }); + } + }; + return
0}>{t("Save")}}> +
{ + event.preventDefault(); + if (disabled || !changed || issues.length > 0) return; + if (await onSave({ public: allowPublic, domains })) { setPublicAccess(null); setText(null); } + }}> +
+
{support.restrict ? t("When off, sandboxes reach only the domains listed below.") : t("{{provider}} cannot restrict outbound traffic, so sandboxes always reach the public internet.", { provider })}
+
+ setPublicAccess(event.target.checked)} /> +
+
+
+
{support.private_domains ? t("One host name per line, such as gitlab.example.com, up to {{count}}. Sandboxes can reach these names, including names on your private network. Wildcards are not accepted.", { count: maxDomains }) : t("{{provider}} sandboxes run in the provider's cloud and cannot reach your private network.", { provider })}
+
+