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
24 changes: 24 additions & 0 deletions apps/web/e2e/sandbox-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
30 changes: 30 additions & 0 deletions apps/web/src/features/sandbox/NetworkSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NetworkForm deployment={deployment("microsandbox")} disabled={false} onSave={async () => true} />);
expect(html).toContain("Allow public internet");
expect(html).toContain("gitlab.example.com");
expect(html).not.toMatch(/<textarea[^>]*disabled/);
expect(html).not.toMatch(/<input[^>]*disabled/);
});

it("disables the domain list where the provider cannot reach a private network", () => {
const html = renderToStaticMarkup(<NetworkForm deployment={deployment("e2b")} disabled={false} onSave={async () => true} />);
expect(html).toMatch(/<textarea[^>]*disabled/);
expect(html).not.toMatch(/<input[^>]*disabled/);
expect(html).toContain("cannot reach your private network");
});
});
68 changes: 68 additions & 0 deletions apps/web/src/features/sandbox/NetworkSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean> }) {
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 <NetworkForm deployment={deployment} disabled={disabled} onSave={save} />;
}

export function NetworkForm({ deployment, disabled, onSave }: { deployment: SandboxDeployment; disabled: boolean; onSave: (network: Network) => Promise<boolean> }) {
const { t, i18n } = useTranslation("sandbox");
const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en";
const current = deployment.specification?.network;
const [publicAccess, setPublicAccess] = useState<boolean | null>(null);
const [text, setText] = useState<string | null>(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 <Section headingId="sandbox-network-heading" title={t("Network")} help={t("A change applies to new sandboxes only. Existing sandboxes keep the policy they started with. The connection to Core and to the Session's model provider is always allowed.")}
actions={<button type="submit" form="sandbox-network-form" className="button outline" disabled={disabled || !changed || issues.length > 0}>{t("Save")}</button>}>
<form id="sandbox-network-form" className="settings-rows" onSubmit={async (event) => {
event.preventDefault();
if (disabled || !changed || issues.length > 0) return;
if (await onSave({ public: allowPublic, domains })) { setPublicAccess(null); setText(null); }
}}>
<div className="settings-row">
<div className="settings-row-label"><label htmlFor="sandbox-network-public">{t("Allow public internet")}</label><HelpTip id="sandbox-network-public-help">{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 })}</HelpTip></div>
<div className="settings-row-control field">
<input id="sandbox-network-public" className="network-switch" type="checkbox" role="switch" aria-describedby="sandbox-network-public-help" checked={allowPublic || !support.restrict} disabled={disabled || !support.restrict} onChange={(event) => setPublicAccess(event.target.checked)} />
</div>
</div>
<div className="settings-row">
<div className="settings-row-label"><label htmlFor="sandbox-network-domains">{t("Extra allowed domains")}</label><HelpTip id="sandbox-network-domains-help">{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 })}</HelpTip></div>
<div className="settings-row-control field">
<textarea id="sandbox-network-domains" className="network-domains" rows={4} spellCheck={false} autoCapitalize="none" value={text ?? current.domains.join("\n")} disabled={disabled || !support.private_domains} aria-invalid={issues.length > 0} aria-describedby="sandbox-network-domains-help" onChange={(event) => setText(event.target.value)} />
{issues.map((issue, index) => <span className="field-error" key={index}>{message(issue)}</span>)}
</div>
</div>
</form>
</Section>;
}
8 changes: 7 additions & 1 deletion apps/web/src/features/sandbox/SandboxDeploymentPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type StartSandboxReset } from "@oac/agents-client";
import { deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxDeployment, type StartSandboxReset, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { TableSkeleton } from "../../components/Skeleton";
Expand All @@ -20,6 +20,7 @@ import { SandboxResetControls } from "./SandboxResetControls";
import "./SandboxManagerView.css";
import { SettingsTabs } from "../settings/SettingsTabs";
import { SuspensionSettings } from "./SuspensionSettings";
import { NetworkSettings } from "./NetworkSettings";

function DeploymentHeader({ actions }: { actions?: ReactNode }) {
const { t } = useTranslation("sandboxNavigation");
Expand Down Expand Up @@ -86,6 +87,10 @@ function DeploymentConfiguration() {
async function update(input: UpdateSandboxDeployment) {
return changeDeployment((signal) => sandboxAdmin.updateDeployment({ ...input, expected_generation: deployment!.generation }, { signal }), true, true);
}
const updateNetwork = (network: SandboxSpecification["network"]) => changeDeployment((signal) => sandboxAdmin.updateDeployment({
provider: configured!.provider as SandboxProvider, expected_generation: deployment!.generation, resources: configured!.specification!.resources, network,
...(configured!.configuration && Object.keys(configured!.configuration).length ? { configuration: configured!.configuration } : {}),
}, { signal }));
const startReset = (input: StartSandboxReset) => changeDeployment((signal) => sandboxAdmin.startReset(input, { signal }));
const cancelReset = (expectedGeneration: number) => changeDeployment((signal) => sandboxAdmin.cancelReset(expectedGeneration, { signal }));
const unknownWrite = writeFailure?.open ? <div className="form-stack" role="alert">
Expand All @@ -97,6 +102,7 @@ function DeploymentConfiguration() {
const reset = configured ? <SandboxResetControls key={`reset:${configurationKey}`} deployment={configured} disabled={disabled} stale={!confirmed} onStart={startReset} onCancel={cancelReset} /> : null;
const sections = [
configured ? <SandboxDeploymentSettings key={configurationKey} deployment={configured} fresh={confirmed} disabled={disabled} onUpdate={update} onRefresh={refreshByUser} refreshing={loading} pending={busy} writeFailure={writeFailure?.editing ? unknownWrite : null} /> : null,
configured?.provider && configured.specification ? <NetworkSettings key={`network:${configurationKey}`} deployment={configured} disabled={disabled || Boolean(configured.reset)} onSave={updateNetwork} /> : null,
configured?.provider ? <SuspensionSettings key="suspension" deployment={configured} disabled={disabled || Boolean(configured.reset)} /> : null,
];

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/sandbox/SandboxManagerView.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,6 @@
.sandbox-reset-dialog p { line-height: 1.6; }

.node-statuses { display: grid; gap: 4px; }

.settings-row .settings-row-control input.network-switch { width: auto; }
.settings-row .settings-row-control textarea.network-domains { width: min(100%, 360px); font-family: var(--font-mono, monospace); resize: vertical; }
32 changes: 32 additions & 0 deletions apps/web/src/features/sandbox/network-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { deploymentContract, type SandboxProvider } from "@oac/agents-client";

const rule = deploymentContract.network;
const pattern = new RegExp(rule.domain_pattern);

export type DomainProblem = "invalid" | "uppercase" | "too_long" | "duplicate";
export type DomainIssue = { domain: string; problem: DomainProblem } | { problem: "too_many" };

/** One host name per line; blank lines are ignored. */
export function parseDomains(text: string): string[] {
return text.split("\n").map((line) => line.trim()).filter(Boolean);
}

/** Checks a domain list against the generated rule while it is typed. */
export function domainIssues(domains: readonly string[]): DomainIssue[] {
const issues: DomainIssue[] = [];
if (domains.length > rule.max_domains) issues.push({ problem: "too_many" });
const seen = new Set<string>();
for (const domain of domains) {
if (seen.has(domain)) issues.push({ domain, problem: "duplicate" });
seen.add(domain);
if (domain.length > rule.max_domain_length) issues.push({ domain, problem: "too_long" });
else if (!pattern.test(domain)) issues.push({ domain, problem: pattern.test(domain.toLowerCase()) ? "uppercase" : "invalid" });
}
return issues;
}

/** What the current Provider declares it can enforce. */
export function networkSupport(provider: SandboxProvider): { restrict: boolean; private_domains: boolean } {
return deploymentContract.providers[provider].network;
}
export const maxDomains = rule.max_domains;
14 changes: 14 additions & 0 deletions apps/web/src/i18n/locales/zh-CN/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { firstRun } from "./first-run";
export const sandbox = {
"Network": "网络",
"A change applies to new sandboxes only. Existing sandboxes keep the policy they started with. The connection to Core and to the Session's model provider is always allowed.": "变更只对新沙箱生效,已有沙箱保持启动时的策略。与 Core 以及 Session 所用模型提供商的连接始终允许。",
"Allow public internet": "允许访问公网",
"When off, sandboxes reach only the domains listed below.": "关闭后,沙箱只能访问下方列出的域名。",
"{{provider}} cannot restrict outbound traffic, so sandboxes always reach the public internet.": "{{provider}} 无法限制出站流量,沙箱始终可以访问公网。",
"Extra allowed domains": "额外允许的域名",
"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.": "每行一个主机名,例如 gitlab.example.com,最多 {{count}} 个。沙箱可以访问这些域名,包括内网域名。不支持通配符。",
"{{provider}} sandboxes run in the provider's cloud and cannot reach your private network.": "{{provider}} 的沙箱运行在提供商的云端,无法访问你的内网。",
"Add at most {{count}} domains.": "最多添加 {{count}} 个域名。",
"{{domain}} is listed twice.": "{{domain}} 重复了。",
"{{domain}} must be lowercase.": "{{domain}} 必须为小写。",
"{{domain}} is too long.": "{{domain}} 太长。",
"{{domain}} is not a valid host name.": "{{domain}} 不是有效的主机名。",
"New sandboxes will run with this policy": "新沙箱将按此策略运行",
"Current usage exceeds a saved limit. Existing sandboxes are preserved; new placements wait for capacity.": "当前用量超过已保存上限。已有沙箱保留;新的分配需等待容量。",
"The idle policy applies to the whole deployment. Each node's active and retained limits are set on Nodes with Edit node.": "空闲策略对整个部署生效;每个节点的活跃与保留上限在节点页用“编辑节点”设置。",
"Reset": "重置",
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa
| Sandbox backend: microsandbox or E2B | **Settings** → **Sandbox and scheduling**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) |
| Sandbox size, E2B key and template build | **Settings** → **Sandbox and scheduling** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the [default size the Provider declares](./sandbox-provider.md#register-the-provider-kind). Existing sandboxes keep their size. The E2B key is write-only and encrypted |
| Nodes and their capacity | **Nodes**: **Add node**, **Edit node** and removal | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and [Add and manage nodes](./getting-started/nodes.md) |
| Sandbox network cap | Not in Web | `/core/v1/sandbox/deployment` | `network.public` and `network.domains`; see [Network](../contracts/agents-api/sandbox-deployment.md#network). Existing sandboxes keep the cap they started with |
| Sandbox network cap | **Settings** → **Sandbox and scheduling** → **Network**: **Allow public internet**, **Extra allowed domains** → **Save** | `/core/v1/sandbox/deployment` | `network.public` and `network.domains`; see [Network](../contracts/agents-api/sandbox-deployment.md#network). Existing sandboxes keep the cap they started with. A control the Provider does not support is disabled |
| Idle suspension | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) |
| Independent workspace storage and tenant capacity | **Settings → Workspace storage** | `/core/v1/workspace-storage` | See [Set up workspace storage](./getting-started/workspace-storage.md); existing workspaces retain their configuration |
| Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests |
Expand Down
Loading
Loading