diff --git a/README.md b/README.md
index e0074a0cd..83ef3704b 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ The OpenAI Agents API, open source and self-hosted. Run Codex, Claude Code or Mi
- **Same API as OpenAI.** Set `OPENAI_BASE_URL` and the [official OpenAI SDK](https://developers.openai.com/api/docs/libraries), or plain HTTP, works unchanged.
- **Your agent, your model.** Each Session runs Codex, Claude Code or MiniMax Code natively, as it runs on your machine, with the model provider you configure. See [Harness capabilities](https://openagentcore.dev/contracts/agents-api/harness-capabilities).
-- **Compute that sleeps, workspaces that stay.** On microsandbox with independent workspace storage, idle sandboxes release compute and resume on a compatible node with their files intact. E2B, Docker and your own Linux, macOS or Windows machine also run Sessions. See [Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider).
+- **Compute that sleeps, workspaces that stay.** On microsandbox, which keeps every workspace on independent storage, idle sandboxes release compute and resume on a compatible node with their files intact. E2B, Docker and your own Linux, macOS or Windows machine also run Sessions. See [Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider).
- **Replaceable parts.** Sandbox Providers, workspace filesystems, Harnesses and model providers plug in through [defined protocols](AGENTS.md#protocols-at-every-boundary).

diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2c7f9e50a..535f2252e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -17,7 +17,7 @@
- **与 OpenAI 相同的 API。** 设置 `OPENAI_BASE_URL` 后,[官方 OpenAI SDK](https://developers.openai.com/api/docs/libraries)或直接的 HTTP 调用无需改动即可使用。
- **你的 Agent,你的模型。** 每个 Session 原生运行 Codex、Claude Code 或 MiniMax Code,使用你配置的模型提供商,与在本机运行一致。详见 [Harness 能力](https://openagentcore.dev/zh/contracts/agents-api/harness-capabilities)。
-- **计算会休眠,工作区会保留。** 在 microsandbox 配合独立工作区存储时,空闲沙箱释放计算资源,并在兼容节点上带着文件恢复。E2B、Docker 以及你自己的 Linux、macOS 或 Windows 机器同样可以运行 Session。详见[沙箱提供商协议](https://openagentcore.dev/zh/docs/sandbox-provider)。
+- **计算会休眠,工作区会保留。** 在 microsandbox 上(每个工作区都保存在独立存储中),空闲沙箱释放计算资源,并在兼容节点上带着文件恢复。E2B、Docker 以及你自己的 Linux、macOS 或 Windows 机器同样可以运行 Session。详见[沙箱提供商协议](https://openagentcore.dev/zh/docs/sandbox-provider)。
- **部件可替换。** 沙箱提供商、工作区文件系统、Harness 和模型提供商都通过[既定协议](AGENTS.md#protocols-at-every-boundary)接入。

diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs
index abf97bdcd..1f2513802 100644
--- a/apps/web/e2e/fixture-console.mjs
+++ b/apps/web/e2e/fixture-console.mjs
@@ -23,6 +23,8 @@ const LOCKOUT_SECONDS = 30;
let state;
const SOURCE_COMMIT = "c0ffee".padEnd(40, "0");
+// microsandbox runs every sandbox on workspace storage, so the installation has it selected.
+const WORKSPACE_STORAGE = { id: "5d0c2a4e-8f6b-4c1d-9e3a-7b2f1a0c6d48", adapter: "nfs", parameters: { root: "/srv/oac-workspaces", namespace_id: "22222222-2222-4222-8222-222222222222", uid: 65532 }, tenant_capacity_mib: 0 };
/**
* config.json's public_url. "public": an HTTPS address, so applications get an API base URL;
@@ -259,6 +261,7 @@ async function adminWrite(request, response, path) {
function adminRead(response, path, url) {
const a = state.admin;
if (path === "/installation") return send(response, 200, installation());
+ if (path === "/workspace-storage") return send(response, 200, WORKSPACE_STORAGE);
if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false });
if (path === "/summary") return send(response, 200, a.summary(url));
if (path === "/request-logs") return send(response, 200, requestLog(url));
diff --git a/apps/web/e2e/sandbox-lifecycle.spec.ts b/apps/web/e2e/sandbox-lifecycle.spec.ts
index db98679e0..ffb53a7e7 100644
--- a/apps/web/e2e/sandbox-lifecycle.spec.ts
+++ b/apps/web/e2e/sandbox-lifecycle.spec.ts
@@ -7,7 +7,7 @@ test("shows each node's retained limit and elapsed phase time without inventing
await page.route("**/core/v1/sandbox/deployment", async (route) => {
const response = await route.fetch();
const deployment = await response.json();
- await route.fulfill({ response, json: { ...deployment, provider: "microsandbox", suspension: { idle_seconds: 300, retention_seconds: 86400 }, specification: { ...deployment.specification, resources: { ...deployment.specification.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } } });
+ await route.fulfill({ response, json: { ...deployment, provider: "microsandbox", suspension: { idle_seconds: 300, retention_seconds: 86400 }, specification: { ...deployment.specification, resources: { ...deployment.specification.resources, root_disk_mib: 8192 } } } });
});
await page.route("**/core/v1/sandbox/nodes", async (route) => {
const response = await route.fetch();
@@ -50,7 +50,7 @@ test("edits deployment idle time without changing generation", async ({ page, re
page.on("request", (sent) => { if (new URL(sent.url()).pathname.startsWith("/core/v1/sandbox/nodes")) nodeReads.push(sent.url()); });
await openConsole(page, request, "settings?id=sandbox");
const deployment = await setDeployment(request, { suspension: { idle_seconds: 300, retention_seconds: 86400 } });
- await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192, environment_disk_mib: 8192 } } });
+ await setDeployment(request, { provider: "microsandbox", specification: { ...deployment.specification!, resources: { ...deployment.specification!.resources, root_disk_mib: 8192 } } });
await page.getByRole("button", { name: "Refresh sandbox state" }).click();
const area = page.getByRole("region", { name: "Automatic suspension" });
const idle = area.getByLabel("Idle before suspension (seconds)");
diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
index 1f2170049..a41e04ded 100644
--- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
+++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx
@@ -58,7 +58,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat
{sandboxProviderLabel(deployment.provider, locale)}
-
{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${spec?.workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes(size.root_disk_mib * MIB), data: formatBytes((size.environment_disk_mib ?? 0) * MIB) })}` : ""}` : MISSING}
+
{size ? `${sizeLabel(size)}${size.root_disk_mib ? ` · ${t("Root disk {{root}} · external workspace", { root: formatBytes(size.root_disk_mib * MIB) })}` : ""}` : MISSING}
{deployment.provider === "e2b" ? <>
{deployment.configuration?.api_url}
{deployment.configuration?.domain}
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
index 3f185fa88..45fad9877 100644
--- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
+++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx
@@ -71,21 +71,21 @@ export function validEndpoint(apiURL: string, domain: string): boolean {
* double of it, disks included where the default declares them. A Provider
* whose configuration selects the size declares none and has no presets.
*/
-function presets(provider: SandboxProvider, workspace?: SandboxSpecification["workspace"]): Record
| null {
- const standard = defaultSandboxResources(provider, workspace);
+function presets(provider: SandboxProvider): Record | null {
+ const standard = defaultSandboxResources(provider);
if (!standard) return null;
const scale = (factor: number): SandboxResources => ({
cpus: Math.max(1, standard.cpus * factor),
memory_mib: standard.memory_mib * factor,
- ...(standard.root_disk_mib ? { root_disk_mib: standard.root_disk_mib * factor, environment_disk_mib: standard.environment_disk_mib! * factor } : {}),
+ ...(standard.root_disk_mib ? { root_disk_mib: standard.root_disk_mib * factor } : {}),
});
return { small: scale(0.5), standard, large: scale(2) };
}
-function presetOf(provider: SandboxProvider, resources: SandboxResources, workspace?: SandboxSpecification["workspace"]): Preset | null {
+function presetOf(provider: SandboxProvider, resources: SandboxResources): Preset | null {
const same = (a: SandboxResources, b: SandboxResources) => a.cpus === b.cpus && a.memory_mib === b.memory_mib
&& (a.root_disk_mib ?? 0) === (b.root_disk_mib ?? 0) && (a.environment_disk_mib ?? 0) === (b.environment_disk_mib ?? 0);
- const all = presets(provider, workspace);
+ const all = presets(provider);
return all ? (Object.keys(all) as Preset[]).find((key) => same(all[key], resources)) ?? null : null;
}
@@ -128,7 +128,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
const workspace = saved?.workspace ?? (provider ? selectedWorkspace(provider, storage) : undefined);
// Choosing a Provider that declares a default size replaces this placeholder.
const [resources, setResources] = useState(current?.specification?.resources ?? { cpus: 0, memory_mib: 0 });
- const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources, current.specification.workspace) ?? "current" : "standard");
+ const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard");
const [apiKey, setApiKey] = useState("");
const [replacementRequested, setReplacementRequested] = useState(false);
const [template, setTemplate] = useState(current?.e2bTemplate ?? "");
@@ -204,9 +204,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
if (next !== provider) {
setRejection(null);
const kept = current ? savedSpecification(next, current.provider, current.specification) : null;
- const proposed = kept?.resources ?? defaultSandboxResources(next, selectedWorkspace(next, storage));
+ const proposed = kept?.resources ?? defaultSandboxResources(next);
if (proposed) setResources(proposed);
- setSize(kept ? presetOf(next, kept.resources, kept.workspace) ?? "current" : "standard");
+ setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard");
}
setProvider(next);
}
@@ -248,7 +248,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
}
const sizeLabel = (value: SandboxResources) => t("{{cpus}} CPU · {{memory}}", { cpus: value.cpus, memory: formatBytes(value.memory_mib * MIB) });
- const diskLabel = (value: SandboxResources) => workspace ? t("Root disk {{root}} · external workspace", { root: formatBytes((value.root_disk_mib ?? 0) * MIB) }) : t("Root disk {{root}} · data disk {{data}}", { root: formatBytes((value.root_disk_mib ?? 0) * MIB), data: formatBytes((value.environment_disk_mib ?? 0) * MIB) });
+ const diskLabel = (value: SandboxResources) => t("Root disk {{root}} · external workspace", { root: formatBytes((value.root_disk_mib ?? 0) * MIB) });
let page: ReactNode;
if (step === "where") {
@@ -262,7 +262,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
);
} else if (step === "backend") {
page = (
-
+
{ choose("microsandbox"); setStep("size"); }} />
{ if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} />
@@ -326,9 +326,9 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
);
} else if (step === "size") {
- const options = provider ? presets(provider, workspace) : null;
+ const options = provider ? presets(provider) : null;
// A saved size outside the presets stays on offer as the current one.
- const kept = saved && provider && presetOf(provider, saved.resources, saved.workspace) === null ? saved.resources : null;
+ const kept = saved && provider && presetOf(provider, saved.resources) === null ? saved.resources : null;
const disks = (value: SandboxResources) => (policy?.disk ? diskLabel(value) : undefined);
page = (
@@ -345,6 +345,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
/>
))}
+ {policy && "workspace" in policy && !workspace ? {t("This backend runs every sandbox on workspace storage. Select it under Settings, Workspace storage first.")}
: null}
@@ -407,7 +408,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} />
{policy?.disk ? <>
{ setResources({ ...resources, root_disk_mib }); setFieldRejection(null); }} />
- {workspace && !workspace.capacity_quota ? {t("Workspace storage is configured by the operator; no capacity quota is enforced.")}
: { setResources({ ...resources, environment_disk_mib }); setFieldRejection(null); }} />}
+ {workspace?.capacity_quota ? { setResources({ ...resources, environment_disk_mib }); setFieldRejection(null); }} /> : workspace ? {t("Workspace storage is configured by the operator; no capacity quota is enforced.")}
: null}
> : null}
: null}
diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts
index 5cd0f464b..0c9e1cf26 100644
--- a/apps/web/src/features/sandbox/deployment-specification.test.ts
+++ b/apps/web/src/features/sandbox/deployment-specification.test.ts
@@ -18,7 +18,7 @@ describe("deployment resources", () => {
expect(resources).toEqual(declared);
if (resources) {
expect(resources).not.toBe(declared);
- expect(validSandboxResources(provider, resources)).toBe(true);
+ expect(validSandboxResources(provider, resources, selectedWorkspace(provider, { tenant_capacity_mib: 0 }))).toBe(true);
}
}
});
@@ -28,12 +28,12 @@ describe("deployment resources", () => {
expect(validSandboxResources("docker", { cpus: 2, memory_mib: 511 })).toBe(false);
expect(validSandboxResources("docker", { cpus: 2, memory_mib: 1048577 })).toBe(false);
expect(validSandboxResources("e2b", { cpus: 2, memory_mib: 2048, root_disk_mib: 1024 })).toBe(false);
- expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023, environment_disk_mib: 8192 })).toBe(false);
+ expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023 }, { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false })).toBe(false);
});
- it("validates external storage without fabricating a disk quota or weakening root disk bounds", () => {
+ it("runs microsandbox only on selected storage, where the environment disk is a quota request", () => {
const workspace = { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false };
- const resources = defaultSandboxResources("microsandbox", workspace)!;
- expect(resources.environment_disk_mib).toBe(0);
+ const resources = defaultSandboxResources("microsandbox")!;
+ expect(resources.environment_disk_mib).toBeUndefined();
expect(validSandboxResources("microsandbox", resources, workspace)).toBe(true);
expect(validSandboxResources("microsandbox", resources)).toBe(false);
expect(validSandboxResources("microsandbox", { ...resources, root_disk_mib: 0 }, workspace)).toBe(false);
@@ -42,15 +42,10 @@ describe("deployment resources", () => {
expect(validSandboxResources("docker", { cpus: 2, memory_mib: 2048 }, workspace)).toBe(false);
expect(validSandboxResources("microsandbox", { ...resources, environment_disk_mib: 8192 }, { ...workspace, capacity_quota: true })).toBe(true);
});
- it("sizes a first initialization from the selected workspace storage", () => {
- const sized = (capacity: number | null) => {
- const workspace = selectedWorkspace("microsandbox", capacity === null ? null : { tenant_capacity_mib: capacity });
- const resources = defaultSandboxResources("microsandbox", workspace)!;
- return { workspace, resources, valid: validSandboxResources("microsandbox", resources, workspace) };
- };
- expect(sized(0)).toMatchObject({ workspace: { capacity_quota: false }, resources: { environment_disk_mib: 0 }, valid: true });
- expect(sized(1024)).toMatchObject({ workspace: { capacity_quota: true }, resources: { environment_disk_mib: 8192 }, valid: true });
- expect(sized(null).workspace).toBeUndefined();
+ it("takes the workspace requirement from the selected storage", () => {
+ expect(selectedWorkspace("microsandbox", { tenant_capacity_mib: 0 })).toMatchObject({ capacity_quota: false });
+ expect(selectedWorkspace("microsandbox", { tenant_capacity_mib: 1024 })).toMatchObject({ capacity_quota: true });
+ expect(selectedWorkspace("microsandbox", null)).toBeUndefined();
expect(selectedWorkspace("docker", { tenant_capacity_mib: 0 })).toBeUndefined();
});
});
diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts
index 229ce3b78..5e6cfc596 100644
--- a/apps/web/src/features/sandbox/deployment-specification.ts
+++ b/apps/web/src/features/sandbox/deployment-specification.ts
@@ -1,9 +1,9 @@
import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client";
/** The size the Provider declares for setup to propose; null when its configuration selects the size. */
-export function defaultSandboxResources(provider: SandboxProvider, workspace?: SandboxSpecification["workspace"]): SandboxResources | null {
+export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null {
const size: SandboxResources | null = deploymentContract.providers[provider].default_resources;
- return size && { ...size, ...(workspace && !workspace.capacity_quota ? { environment_disk_mib: 0 } : {}) };
+ return size && { ...size };
}
/** The workspace requirement a Provider takes from the selected storage; none when it owns its workspaces or no storage is selected. */
@@ -12,15 +12,14 @@ export function selectedWorkspace(provider: SandboxProvider, storage: { tenant_c
return storage && "workspace" in policy ? { ...policy.workspace, capacity_quota: storage.tenant_capacity_mib > 0 } : undefined;
}
-/** Core keeps owned disk limits; external workspace capacity is optional and requires real quota support. */
+/** A Provider that declares a workspace policy runs every Environment on the selected storage; its environment disk is only a quota request. */
export function validSandboxResources(provider: SandboxProvider, resources: SandboxResources, workspace?: SandboxSpecification["workspace"]): boolean {
const policy = deploymentContract.providers[provider];
- if (workspace) {
- if (!("workspace" in policy) || workspace.attachment !== policy.workspace.attachment || (policy.workspace.user_xattr && !workspace.user_xattr) || ((resources.environment_disk_mib ?? 0) > 0 && !workspace.capacity_quota)) return false;
- }
+ if ("workspace" in policy !== Boolean(workspace)) return false;
+ if (workspace && "workspace" in policy && (workspace.attachment !== policy.workspace.attachment || (policy.workspace.user_xattr && !workspace.user_xattr))) return false;
return deploymentContract.resources.every((rule) => {
- const [min, max] = workspace && rule.name === "environment_disk_mib" && (resources.environment_disk_mib ?? 0) === 0 ? [0, 0] : !rule.omit_zero ? [rule.min, rule.max] : deploymentContract.providers[provider].disk ? [deploymentContract.minimum_disk, rule.max] : [0, 0];
const value = resources[rule.name] ?? 0;
+ const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : rule.name === "environment_disk_mib" && workspace?.capacity_quota ? [value === 0 ? 0 : deploymentContract.minimum_disk, rule.max] : rule.name === "root_disk_mib" && policy.disk ? [deploymentContract.minimum_disk, rule.max] : [0, 0];
return Number.isInteger(value) && value >= min && value <= max;
});
}
diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts
index aa4ed5dc0..6e38ff03c 100644
--- a/apps/web/src/i18n/locales/en/core-errors.ts
+++ b/apps/web/src/i18n/locales/en/core-errors.ts
@@ -2,6 +2,7 @@
export const coreErrors = {
"invalid_workspace_configuration": "Invalid workspace storage configuration.",
"workspace_operation_unsupported": "The selected workspace storage does not support the operation or sandbox combination.",
+ "workspace_storage_required": "This sandbox provider needs workspace storage. Select workspace storage first.",
"workspace_storage_not_found": "Workspace storage is not configured or the requested object does not exist.",
"workspace_storage_conflict": "Workspace storage ownership or configuration conflicts with the current state.",
"workspace_storage_unavailable": "Workspace storage is unavailable or an operation remains unconfirmed.",
diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts
index 9b117c70b..570ab2c25 100644
--- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts
+++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts
@@ -1,6 +1,7 @@
export const coreErrors = {
"invalid_workspace_configuration": "工作区存储配置无效。",
"workspace_operation_unsupported": "所选工作区存储不支持该操作或沙箱组合。",
+ "workspace_storage_required": "该沙箱提供方需要工作区存储。请先选择工作区存储。",
"workspace_storage_not_found": "尚未配置工作区存储或请求的对象不存在。",
"workspace_storage_conflict": "工作区存储归属或配置与当前状态冲突。",
"workspace_storage_unavailable": "工作区存储不可用或操作尚未确认。",
diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts
index 8095689ae..226abd64d 100644
--- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts
+++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts
@@ -314,8 +314,9 @@ export const sandbox = {
"Connect E2B": "连接 E2B",
"Core address": "Core 地址",
"Custom size in advanced settings": "在高级设置里自定义规格",
- "Data disk at /environment (MiB)": "数据盘 /environment(MiB)",
- "microsandbox, the recommended default, runs each sandbox as a lightweight virtual machine: stronger isolation and its own root and data disks with size limits, but the host needs KVM. Docker runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.": "microsandbox(推荐的默认沙箱提供商):每个沙箱是一个轻量虚拟机,隔离更强,有独立的根盘和数据盘并能限制大小,但主机需要支持 KVM。Docker:每个沙箱是一个共用主机内核的容器,能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。",
+ "Capacity quota at /environment (MiB)": "/environment 容量配额(MiB)",
+ "This backend runs every sandbox on workspace storage. Select it under Settings, Workspace storage first.": "这个后端的每个沙箱都运行在工作区存储上。请先在“设置”的“工作区存储”中选择。",
+ "microsandbox, the recommended default, runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM. Docker runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.": "microsandbox(推荐的默认沙箱提供商):每个沙箱是一个轻量虚拟机,隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。Docker:每个沙箱是一个共用主机内核的容器,能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。",
"E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with microsandbox (recommended) or Docker.": "E2B 云端:沙箱跑在 E2B 的云上,不用管理机器,由 E2B 计费。自有机器:沙箱跑在你添加的主机上,用 microsandbox(推荐)或 Docker。",
"Each sandbox": "每个沙箱",
"How big is each sandbox?": "每个沙箱多大?",
@@ -324,7 +325,6 @@ export const sandbox = {
"Next": "下一步",
"Review and save": "确认并保存",
"Root disk (MiB)": "根盘(MiB)",
- "Root disk {{root}} · data disk {{data}}": "根盘 {{root}} · 数据盘 {{data}}",
"Sandboxes run on": "运行在",
"Saving…": "保存中…",
"Small": "小",
diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md
index dd4dd2f67..549bb2396 100644
--- a/contracts/agents-api/core-errors.md
+++ b/contracts/agents-api/core-errors.md
@@ -66,7 +66,7 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf
| `harness_config_invalid` | `harness_config` | omitted | The deployment default's native parameters are unsupported or invalid |
| `invalid_sandbox_configuration` | `resources.cpus` | `min`: 1, `max`: 255 | The CPU count is outside the supported bounds |
| `invalid_sandbox_configuration` | `resources.memory_mib` | `min`: 512, `max`: 1048576 | Memory is outside the supported bounds |
-| `invalid_sandbox_configuration` | `resources.root_disk_mib` or `resources.environment_disk_mib` | `min`: 1024 for microsandbox; `min`: 0, `max`: 0 for Docker and E2B | Disk capacity is missing or unsupported by the provider |
+| `invalid_sandbox_configuration` | `resources.root_disk_mib` or `resources.environment_disk_mib` | `min`: 1024 for microsandbox (`environment_disk_mib` also accepts 0); `min`: 0, `max`: 0 for Docker and E2B | Disk capacity is missing or unsupported by the provider |
Bounds are validation constants, never submitted values. Node names are limited in bytes; Project and key names in trimmed Unicode characters without control characters. Only the first failure is reported, in this order: model provider URL, protocol, key, limits, then the Harness's protocol; sandbox resources CPU, memory, then disk. Model-provider field errors inside a `model_provider` object keep that object's field as `param`. An unknown sandbox provider returns an error without these fields.
@@ -80,6 +80,7 @@ These codes have null `param` and no `details`. [Sandbox deployment](./sandbox-d
| 400 | `workspace_operation_unsupported` | The selected workspace storage does not support the operation or sandbox combination |
| 404 | `workspace_storage_not_found` | Workspace storage is not configured or the requested object does not exist |
| 409 | `workspace_storage_conflict` | Workspace storage ownership or configuration conflicts with the current state |
+| 409 | `workspace_storage_required` | The sandbox provider runs every Environment on workspace storage and none is selected |
| 503 | `workspace_storage_unavailable` | Workspace storage is unavailable or an operation remains unconfirmed |
| 400 | `sandbox_operation_unsupported` | The selected sandbox provider does not support the operation |
| 401 | `invalid_admin_key` | The bearer credential is not a valid Core key |
diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md
index ad73b3df9..2cb143ca0 100644
--- a/contracts/agents-api/harness-capabilities.md
+++ b/contracts/agents-api/harness-capabilities.md
@@ -64,4 +64,4 @@ These operations need a workspace, so they apply to hosted and self-hosted place
| [Plugin MCP over stdio](./environments.md#plugin-mcp) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted |
| Plugin MCP over HTTP | Admitted, with literal headers or HTTPS bearer | Admitted, with literal headers or HTTPS bearer | Verified: self-hosted; admitted: hosted; literal headers or HTTPS bearer |
-External workspace storage additionally requires the selected Harness declaration and Runtime to support `retained_native_history`; owned workspace storage does not. The [Workspace filesystem protocol](../../docs/workspace-provider.md) owns storage combination admission.
+External workspace storage additionally requires the selected Harness declaration and Runtime to support `retained_native_history`; Docker and E2B, which keep their own disks, do not. microsandbox always uses external storage, so MiniMax Code, which does not declare the capability, cannot run on it. The [Workspace filesystem protocol](../../docs/workspace-provider.md) owns storage combination admission.
diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md
index e6447d1c2..d312e0b0e 100644
--- a/contracts/agents-api/index.md
+++ b/contracts/agents-api/index.md
@@ -134,6 +134,7 @@ Each item is Core's deliberate or native behavior where the official service beh
**Environments and Templates**
- Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the existing [input reservation deadline](./environments.md#reservations).
+- MiniMax Code does not declare `retained_native_history`, so it cannot run on microsandbox, which keeps every Environment on workspace storage; Core rejects the Session ([Harness capabilities](./harness-capabilities.md)).
- Only `enabled` networking runs, so Sessions that need `disabled` or `restricted` are rejected ([restricted network policy](./environments.md#restricted-network)).
- `packages.system` is rejected; system packages must be preinstalled.
- Cold continuation does not restore process memory, background processes or temporary system-disk changes, and completed setup is not replayed ([Environment persistence](./environments.md#runtime-capability-preparation)). On compatible microsandbox nodes with retained NFS storage, Codex and Claude Code are qualified for [checkpoint transfer](../../docs/sandbox-provider.md#checkpoint-transfer) and for continuation after [confirmed compute loss](../../docs/sandbox-provider.md#confirmed-compute-loss). Takeover of an unconfirmed old writer, host-crash durability and arbitrary crash-damaged history are unqualified.
diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md
index 6edd472b8..7112c9f9f 100644
--- a/contracts/agents-api/node-generation-protocol.md
+++ b/contracts/agents-api/node-generation-protocol.md
@@ -106,7 +106,7 @@ A failed response carries no result, except an `info` that is an exact-reference
Node startup and generation loading validate complete Provider operation declarations before accepting work, and the Core proxy uses the same registered declaration, so an unsupported operation rejects before node resolution or native I/O. The [operation contract](../../docs/sandbox-provider.md#explicit-operation-contracts) owns the inventory. An `unsupported` response carries an `unsupported` object with the exact method `operation` and an authored safe `reason`; the proxy checks both against the request. Missing, malformed or mismatched evidence is an unconfirmed result, never proof that a mutation was rejected. Unsupported stays distinct from observation unavailability and unknown compute or command results, and it neither settles resource ownership nor authorizes a replay.
-Create bootstrap and Resume requests may carry an optional workspace filesystem binding. The node validates its tenant and Environment against the allocation reference, its immutable ObjectID, and its attachment configuration ID against the supplied configuration before forwarding it. The filesystem resolver validates adapter-native ownership. A missing binding selects owned storage; a present binding cannot fall back. Error responses may retain a Resume target only when its nonempty native ID, name, generation and snapshot provenance match the requested target; this is cleanup evidence, never successful restore.
+Create bootstrap and Resume requests may carry an optional workspace filesystem binding. The node validates its tenant and Environment against the allocation reference, its immutable ObjectID, and its attachment configuration ID against the supplied configuration before forwarding it. The filesystem resolver validates adapter-native ownership. A Provider whose policy declares workspace storage rejects a missing binding; a Provider without it rejects a present one. Neither falls back. Error responses may retain a Resume target only when its nonempty native ID, name, generation and snapshot provenance match the requested target; this is cleanup evidence, never successful restore.
## Generation control
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index c72e22920..26db1bfd2 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -48,17 +48,17 @@ The request has no Core address. Core derives the deployment's `core_url` from t
| `cpus` | Integer, 1 through 255 |
| `memory_mib` | Integer, 512 through 1048576 MiB |
| `root_disk_mib` | microsandbox: at least 1024 MiB; Docker and E2B: omitted or zero |
-| `environment_disk_mib` | microsandbox owned disk: at least 1024 MiB; external filesystem: each new workspace's admitted capacity, where zero requests no quota and a positive value must be at least 1024 MiB and requires enforced quotas; Docker and E2B: omitted or zero |
+| `environment_disk_mib` | microsandbox: each new workspace's admitted capacity on workspace storage, where zero (the default) requests no quota and a positive value must be at least 1024 MiB and requires enforced quotas; Docker and E2B: omitted or zero |
These limits describe each sandbox. A node's `max_active` and `max_retained` are separate reservation limits, set as [Node capacity](#nodes-and-allocations) describes; no host measurement permits exceeding either once set. Native providers may reject values that pass these bounds.
Docker applies the CPU and memory limits and checks the running container's limits and exact image; it has no hard root or workspace disk quota. E2B CPU and memory must equal the exact ready template build, which Core validates through the pinned SDK before saving; disk capacity stays part of the template. Neither provider accepts a disk quota it cannot enforce. An E2B selection may omit `resources`: Core then stores the build's CPU count and memory as `cpus` and `memory_mib`, returned in `specification.resources` without disk fields. On restart Core loads the committed E2B selection without validating the template build again, so an E2B outage never blocks inspection or cleanup; new selections still require validation.
-microsandbox configures the CPUs, memory, a managed root disk and a separate owned disk at `/environment`. The [microsandbox helper](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md) describes how restore handles these limits.
+microsandbox configures the CPUs, memory and a managed root disk. It runs every Environment on [workspace storage](../../docs/workspace-provider.md) mounted at `/environment`. The [microsandbox helper](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md) describes how restore handles these limits.
-The optional response `specification.workspace` is an immutable capability receipt derived from the selected [workspace filesystem](../../docs/workspace-provider.md), not an independently writable setting. It contains only `attachment`, `user_xattr` and `capacity_quota`; filesystem configuration, paths and credentials stay outside the sandbox specification. Core validates the combined declarations before saving, and retained generation validation and node construction use this receipt. Missing `workspace` preserves owned disk mode, including its existing disk bounds. Create and Resume must provide a binding exactly when the frozen generation selects external storage.
+The optional response `specification.workspace` is an immutable capability receipt derived from the selected [workspace filesystem](../../docs/workspace-provider.md), not an independently writable setting. It contains only `attachment`, `user_xattr` and `capacity_quota`; filesystem configuration, paths and credentials stay outside the sandbox specification. Core validates the combined declarations before saving, and retained generation validation and node construction use this receipt. microsandbox always has the receipt, and a deployment saved before workspace storage is selected returns `workspace_storage_required`. Docker and E2B have no receipt and keep their own disks. Create and Resume provide a binding exactly when the frozen generation has the receipt.
-To enable external storage for an existing owned deployment, select the filesystem configuration first, then update the sandbox deployment with compatible resources (`environment_disk_mib: 0` for a filesystem without quotas). Selecting the filesystem does not alter existing generations or allocations. The next sandbox update derives the external receipt; old owned allocations keep their owned disks. A filesystem selection must support the provider's declared requirements. An already external deployment requires the same `attachment` and `user_xattr`; the selection may change `capacity_quota`, after which new workspaces are refused until a sandbox update sets a matching `environment_disk_mib`. Existing workspaces keep the capacity they were admitted with.
+Select the filesystem configuration before the microsandbox deployment. Selecting the filesystem does not alter existing generations or allocations; the next sandbox update derives the receipt. A filesystem selection must support the provider's declared requirements. An existing deployment requires the same `attachment` and `user_xattr`; the selection may change `capacity_quota`, after which new workspaces are refused until a sandbox update sets a matching `environment_disk_mib`. Existing workspaces keep the capacity they were admitted with.
### E2B configuration
@@ -215,8 +215,8 @@ Some fields keep one name across providers but differ in meaning, or do not appl
| Field | microsandbox | E2B | Docker |
| --- | --- | --- | --- |
-| Independent workspace filesystem | [Kernel NFS adapter](../../docs/workspace-provider.md#kernel-nfs-adapter); configured separately from compute | Unsupported | Unsupported |
-| Deployment `specification.resources` | `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota |
+| Independent workspace filesystem | Required: [Kernel NFS adapter](../../docs/workspace-provider.md#kernel-nfs-adapter), configured separately from compute | Unsupported | Unsupported |
+| Deployment `specification.resources` | `cpus`, `memory_mib`, `root_disk_mib` and, as a quota request, `environment_disk_mib` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota |
| Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved | Absent: `metadata` is empty |
| Deployment `suspension` | `{idle_seconds, retention_seconds}` | `null`; Core does not suspend E2B sandboxes | `null` |
| Deployment `resources.allocations`, `resources.pending` | Totals across all nodes | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes |
@@ -236,6 +236,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl
| --- | --- | --- |
| 400 | `invalid_request_error` or `invalid_request` | A malformed request |
| 400 | `invalid_sandbox_configuration` | A validated configuration diagnostic |
+| 409 | `workspace_storage_required` | The provider runs every Environment on workspace storage and none is selected |
| 409 | `sandbox_generation_stale` | `expected_generation` is not current; `current_generation` gives the current one |
| 409 | `sandbox_reset_required` | A different backend; the details name the current and requested providers |
| 409 | `sandbox_not_configured` | A change to an unconfigured deployment |
diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md
index 42869c79c..1520547ee 100644
--- a/contracts/agents-api/wire-semantics.md
+++ b/contracts/agents-api/wire-semantics.md
@@ -188,6 +188,7 @@ A Session's effective configuration must also pass execution admission, which ap
| A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting, `programmatic_tool_calling` value or `text.verbosity` the Harness does not declare | A message naming the field, such as `reasoning.effort "max" is not supported by this Harness.`; see [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) and [Harness capabilities](./harness-capabilities.md#execution-and-input) |
| A function name that is blank or repeated | "Function names must be nonempty and unique." |
| Two MCP servers with one label | "Execution requires distinct MCP server labels." |
+| A Harness that does not declare `retained_native_history`, on a sandbox provider that keeps every Environment on workspace storage ([Harness capabilities](./harness-capabilities.md)) | "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk." |
A per-Session `tools` replacement admits a Session whose saved tools would be rejected. Support for each tool and Harness is in [execution and tools](./execution-tools.md).
diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md
index c2af3a9c1..2dfadfb04 100644
--- a/contracts/agents-api/zh/core-errors.md
+++ b/contracts/agents-api/zh/core-errors.md
@@ -1,7 +1,7 @@
---
title: "Core 管理错误"
source: contracts/agents-api/core-errors.md
-source_hash: 35a964412b5cd64d552df2e10295dc23cb91e8ba43794a932957269f546b1125
+source_hash: 9229fb99268c1b3f5a97a4c97e762faaf0e45bada46e6b1f82d28798e090d205
---
`/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。
@@ -68,7 +68,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封
| `harness_config_invalid` | `harness_config` | 省略 | 部署默认配置的原生参数不受支持或无效 |
| `invalid_sandbox_configuration` | `resources.cpus` | `min`:1,`max`:255 | CPU 数量超出支持范围 |
| `invalid_sandbox_configuration` | `resources.memory_mib` | `min`:512,`max`:1048576 | 内存超出支持范围 |
-| `invalid_sandbox_configuration` | `resources.root_disk_mib` 或 `resources.environment_disk_mib` | `min`:microsandbox 为 1024;Docker 和 E2B 的 `min`:0,`max`:0 | 磁盘容量缺失或提供商不支持 |
+| `invalid_sandbox_configuration` | `resources.root_disk_mib` 或 `resources.environment_disk_mib` | `min`:microsandbox 为 1024(`environment_disk_mib` 也接受 0);Docker 和 E2B 的 `min`:0,`max`:0 | 磁盘容量缺失或提供商不支持 |
这些边界是验证常量,绝不是提交的值。节点名称按字节数限制;项目名称和键名称按去除首尾空白后的 Unicode 字符数限制,且不得包含控制字符。系统仅按以下顺序报告第一个失败项:模型提供商 URL、协议、密钥、限制,然后是 Harness 协议;沙箱资源依次为 CPU、内存,然后是磁盘。`model_provider` 对象内的模型提供商字段错误仍以该对象的相应字段作为 `param`。未知的沙箱提供商返回一个不含这些字段的错误。
@@ -82,6 +82,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封
| 400 | `workspace_operation_unsupported` | 所选工作区存储不支持该操作或沙箱组合 |
| 404 | `workspace_storage_not_found` | 尚未配置工作区存储或请求的对象不存在 |
| 409 | `workspace_storage_conflict` | 工作区存储归属或配置与当前状态冲突 |
+| 409 | `workspace_storage_required` | 沙箱提供方在工作区存储上运行每个 Environment,但尚未选择存储 |
| 503 | `workspace_storage_unavailable` | 工作区存储不可用或操作尚未确认 |
| 400 | `sandbox_operation_unsupported` | 所选沙箱提供商不支持该操作 |
| 401 | `invalid_admin_key` | Bearer 凭据不是有效的 Core 密钥 |
diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md
index 33c002594..e9e5e2d75 100644
--- a/contracts/agents-api/zh/harness-capabilities.md
+++ b/contracts/agents-api/zh/harness-capabilities.md
@@ -1,7 +1,7 @@
---
title: "Harness 能力"
source: contracts/agents-api/harness-capabilities.md
-source_hash: 8ccea275f1d4bef039b9c4467388374a9bbd987c690c1f435733c6628a834ed4
+source_hash: a09c99bf3ad9782ffbf3d9d2f4d6313f93f39ee92ea5b919635a96a70b490edd
---
本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。
@@ -66,4 +66,4 @@ Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、P
| [stdio Plugin MCP](environments.md#plugin-mcp) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 |
| HTTP Plugin MCP | 已准入,字面值头或 HTTPS bearer | 已准入,字面值头或 HTTPS bearer | 已验证:自托管;已准入:托管;字面值头或 HTTPS bearer |
-外部工作区存储还要求所选 Harness 声明和 Runtime 支持 `retained_native_history`;自带工作区存储不要求此能力。[工作区文件系统协议](../../../docs/zh/workspace-provider.md)定义存储组合准入规则。
+外部工作区存储还要求所选 Harness 声明和 Runtime 支持 `retained_native_history`;使用自有磁盘的 Docker 和 E2B 不要求此能力。microsandbox 始终使用外部存储,因此未声明该能力的 MiniMax Code 无法在其上运行。[工作区文件系统协议](../../../docs/zh/workspace-provider.md)定义存储组合准入规则。
diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md
index 4d984de38..838d1ef7e 100644
--- a/contracts/agents-api/zh/index.md
+++ b/contracts/agents-api/zh/index.md
@@ -1,7 +1,7 @@
---
title: "OpenAI 兼容性"
source: contracts/agents-api/index.md
-source_hash: fd77d3617bd8a124382a8d7e8c95adb824fc0da73c31fce2ff475c920e3b0626
+source_hash: 5766fdb6de7cdf07f02de16e2fdaf3fea0ce4d3b357d5648f15e9d709483b819
---
Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。
@@ -137,6 +137,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh
**Environments 和模板**
- Hosted 工作区准入受[租户容量策略](../../../docs/zh/configuration.md#tenant-capacity)及既有[输入预留截止时间](environments.md#reservations)限制。
+- MiniMax Code 未声明 `retained_native_history`,因此无法在 microsandbox 上运行(其每个 Environment 都使用工作区存储);Core 会拒绝该 Session([Harness 能力](./harness-capabilities.md))。
- 只有 `enabled` 网络可以运行,因此需要 `disabled` 或 `restricted` 的 Session 会被拒绝([restricted network policy](environments.md#restricted-network))。
- `packages.system` 会被拒绝;系统软件包必须预先安装。
- 冷续接不恢复进程内存、后台进程或临时系统盘修改,已完成的 setup 不会重放(参见 [Environment 持久化](environments.md#runtime-capability-preparation))。在使用保留 NFS 存储的兼容 microsandbox 节点上,Codex 和 Claude Code 已通过[检查点转移](../../../docs/zh/sandbox-provider.md#checkpoint-transfer)以及[已确认的计算丢失](../../../docs/zh/sandbox-provider.md#confirmed-compute-loss)后续接的资格验证。接管未确认停止的旧写入者、主机崩溃时的持久性及任意崩溃受损历史的完整性仍未通过资格验证。
diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md
index 5931080f4..bcec4415b 100644
--- a/contracts/agents-api/zh/node-generation-protocol.md
+++ b/contracts/agents-api/zh/node-generation-protocol.md
@@ -1,7 +1,7 @@
---
title: "沙箱节点协议"
source: contracts/agents-api/node-generation-protocol.md
-source_hash: a8135ed519bdc897237cef7f69c78dde638ab76877c67f096b1e5aef9f28b80b
+source_hash: cc071a27e2a33ecbecf7d5f4a3aa7cbd5d61e0f1cac114df686eea8df0baf5e3
---
沙箱节点协议连接 Core 与 Docker 或 microsandbox 主机:主机针对其本地提供商执行提供商操作,并报告就绪状态、主机测量值及其持有的部署代次。
@@ -108,7 +108,7 @@ Core 发送包含以下内容的 `request` 帧:
节点启动和代次加载会在接受工作前验证完整的提供商操作声明,Core 代理使用同一份已注册声明,因此不支持的操作会在节点解析或原生 I/O 之前被拒绝。操作清单由[操作契约](../../../docs/zh/sandbox-provider.md#explicit-operation-contracts)维护。`unsupported` 响应包含一个 `unsupported` 对象,其中有精确的方法 `operation` 和经作者编写且安全的 `reason`;代理会将两者与请求进行核对。证据缺失、格式错误或不匹配会得到 `unconfirmed` 结果,而绝不会证明变更操作被拒绝。`unsupported` 始终不同于观察不可用,也不同于计算或命令结果未知;它既不确定资源所有权,也不授权重放。
-Create 引导和 Resume 请求可携带可选的工作区文件系统绑定。节点在转发前校验其租户及 Environment 与分配引用一致、ObjectID 不可变且有效,以及挂载配置 ID 与所传配置一致。文件系统解析器负责适配器原生所有权校验。未提供绑定时选择自有存储;已提供绑定时不得回退。错误响应仅可保留原生 ID 非空且名称、代次、快照来源均匹配请求的 Resume 目标;这仅为清理证据,不代表恢复成功。
+Create 引导和 Resume 请求可携带可选的工作区文件系统绑定。节点在转发前校验其租户及 Environment 与分配引用一致、ObjectID 不可变且有效,以及挂载配置 ID 与所传配置一致。文件系统解析器负责适配器原生所有权校验。策略声明了工作区存储的提供商拒绝缺失的绑定,未声明的提供商拒绝已提供的绑定,两者都不回退。错误响应仅可保留原生 ID 非空且名称、代次、快照来源均匹配请求的 Resume 目标;这仅为清理证据,不代表恢复成功。
## 代次控制 {#generation-control}
diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md
index cbfa78e05..72ecae899 100644
--- a/contracts/agents-api/zh/sandbox-deployment.md
+++ b/contracts/agents-api/zh/sandbox-deployment.md
@@ -1,7 +1,7 @@
---
title: "沙箱部署"
source: contracts/agents-api/sandbox-deployment.md
-source_hash: e84e173775d35451be7a77b58946c1786327f77aa627fa169f676648a6933943
+source_hash: 385786135904715e0b92b5978cade8a4313d4004610b4ea5a1310d0b6ff30900
---
沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。
@@ -50,18 +50,18 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 `
| `cpus` | 整数,1 到 255 |
| `memory_mib` | 整数,512 到 1048576 MiB |
| `root_disk_mib` | microsandbox:至少 1024 MiB;Docker 和 E2B:省略或为零 |
-| `environment_disk_mib` | microsandbox 自有磁盘:至少 1024 MiB;外部文件系统:每个新工作区的获准容量,零表示不请求配额,正值必须至少为 1024 MiB 且要求强制执行配额;Docker 和 E2B:省略或为零 |
+| `environment_disk_mib` | microsandbox:工作区存储上每个新工作区的获准容量,零(默认值)表示不请求配额,正值必须至少为 1024 MiB 且要求强制执行配额;Docker 和 E2B:省略或为零 |
这些限制描述每个沙箱。节点的 `max_active` 和 `max_retained` 是独立的预留限制,设置方式见[节点与分配](#nodes-and-allocations);限制一经设置,任何主机测量值都不允许超过其中任何一个。即使数值满足这些边界,原生提供商仍可能拒绝这些值。
Docker 应用 CPU 和内存限制,并检查运行中容器的限制和精确镜像;它没有硬性的根磁盘或工作区磁盘配额。E2B 的 CPU 和内存必须与精确的就绪模板构建一致,Core 会在保存前通过固定版本 SDK 进行验证;磁盘容量仍属于模板的一部分。两种提供商都不接受其无法强制执行的磁盘配额。E2B 选择可以省略 `resources`:此时 Core 将构建的 CPU 数量和内存存储为 `cpus` 和 `memory_mib`,并通过 `specification.resources` 返回,不带磁盘字段。重启时,Core 会加载已提交的 E2B 选择,而不会再次验证模板构建,因此 E2B 中断绝不会阻碍检查或清理;新选择仍需验证。
-microsandbox 会配置 CPU、内存、托管根磁盘,以及位于 `/environment` 的独立自有磁盘。[microsandbox 辅助工具](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md)说明了 restore 如何处理这些限制。
+microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/environment` 的[工作区存储](../../../docs/zh/workspace-provider.md)上运行每个 Environment。[microsandbox 辅助工具](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/tools/microsandbox-provider/README.md)说明了 restore 如何处理这些限制。
-响应中可选的 `specification.workspace` 是从所选[工作区文件系统](../../../docs/zh/workspace-provider.md)派生的不可变能力回执,不是可独立写入的设置。它仅包含 `attachment`、`user_xattr` 和 `capacity_quota`;文件系统配置、路径和凭据不进入 Sandbox specification。Core 保存前校验组合声明,保留代次校验与节点构造均使用此回执。缺失 `workspace` 时保留自有磁盘模式及现有容量边界。Create 和 Resume 必须且仅当固定代次选择外部存储时提供绑定。
+响应中可选的 `specification.workspace` 是从所选[工作区文件系统](../../../docs/zh/workspace-provider.md)派生的不可变能力回执,不是可独立写入的设置。它仅包含 `attachment`、`user_xattr` 和 `capacity_quota`;文件系统配置、路径和凭据不进入 Sandbox specification。Core 保存前校验组合声明,保留代次校验与节点构造均使用此回执。microsandbox 始终具备该回执,尚未选择工作区存储时保存部署会返回 `workspace_storage_required`。Docker 和 E2B 没有回执,继续使用自有磁盘。Create 和 Resume 必须且仅当固定代次具备该回执时提供绑定。
-为现有自有磁盘部署启用外部存储时,先选择文件系统配置,再使用兼容资源更新 Sandbox 部署(不支持配额的文件系统使用 `environment_disk_mib: 0`)。选择文件系统不会改变现有代次或 allocation。下一次 Sandbox 更新派生外部能力回执;旧的自有磁盘 allocation 保留原磁盘。文件系统选择必须支持提供商声明的要求。已采用外部存储的部署要求相同的 `attachment` 和 `user_xattr`;选择可以更改 `capacity_quota`,此后在 Sandbox 更新设置相匹配的 `environment_disk_mib` 之前拒绝新工作区。既有工作区保持其获准时的容量。
+先选择文件系统配置,再配置 microsandbox 部署。选择文件系统不会改变现有代次或 allocation;下一次 Sandbox 更新派生该回执。文件系统选择必须支持提供商声明的要求。现有部署要求相同的 `attachment` 和 `user_xattr`;选择可以更改 `capacity_quota`,此后在 Sandbox 更新设置相匹配的 `environment_disk_mib` 之前拒绝新工作区。既有工作区保持其获准时的容量。
### E2B 配置 {#e2b-configuration}
@@ -220,8 +220,8 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。
| 字段 | microsandbox | E2B | Docker |
| --- | --- | --- | --- |
-| 独立工作区文件系统 | [Kernel NFS adapter](../../../docs/zh/workspace-provider.md#kernel-nfs-adapter);与计算资源分开配置 | 不支持 | 不支持 |
-| 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`;无磁盘配额 |
+| 独立工作区文件系统 | 必需:[Kernel NFS adapter](../../../docs/zh/workspace-provider.md#kernel-nfs-adapter),与计算资源分开配置 | 不支持 | 不支持 |
+| 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib`,以及作为配额请求的 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`;无磁盘配额 |
| 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 |
| 部署 `suspension` | `{idle_seconds, retention_seconds}` | `null`;Core 不暂停 E2B 沙箱 | `null` |
| 部署 `resources.allocations`、`resources.pending` | 所有节点的总数 | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | 所有节点的总数 |
@@ -241,6 +241,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。
| --- | --- | --- |
| 400 | `invalid_request_error` 或 `invalid_request` | 请求格式错误 |
| 400 | `invalid_sandbox_configuration` | 配置验证产生的诊断错误 |
+| 409 | `workspace_storage_required` | 提供商在工作区存储上运行每个 Environment,但尚未选择存储 |
| 409 | `sandbox_generation_stale` | `expected_generation` 不是当前值;`current_generation` 提供当前代次 |
| 409 | `sandbox_reset_required` | 提供商不同;详情会指明当前提供商和请求的提供商 |
| 409 | `sandbox_not_configured` | 更改未配置的部署 |
diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md
index 0614c1975..b9c0810c5 100644
--- a/contracts/agents-api/zh/wire-semantics.md
+++ b/contracts/agents-api/zh/wire-semantics.md
@@ -1,7 +1,7 @@
---
title: "API 行为"
source: contracts/agents-api/wire-semantics.md
-source_hash: 50998ccc26430755f2a81e634d33def91e96cfe377149aec66dc04909d972cb4
+source_hash: 1f374322fcfd4a9fda8162004342162b1c3b8e3b5f12c2f2871f68bd593751da
---
已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。
@@ -190,6 +190,7 @@ Session 的有效配置还必须通过执行准入,无论配置来自保存还
| Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值或 `text.verbosity` | 指出字段的消息,例如 `reasoning.effort "max" is not supported by this Harness.`;见 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) 和 [Harness 能力](harness-capabilities.md#execution-and-input) |
| 函数名称为空或重复 | "Function names must be nonempty and unique." |
| 两个使用同一标签的 MCP 服务器 | "Execution requires distinct MCP server labels." |
+| 未声明 `retained_native_history` 的 Harness,运行在把每个 Environment 都放在 workspace 存储上的 sandbox provider 上([Harness 能力](harness-capabilities.md)) | "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk." |
每 Session 的 `tools` 替换可以允许一个其已保存 tools 原本会被拒绝的 Session。每种工具和 Harness 的支持情况见 [execution and tools](execution-tools.md)。
diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py
index a386b8a45..b239dcfe4 100644
--- a/deploy/node/node_install.py
+++ b/deploy/node/node_install.py
@@ -51,6 +51,8 @@ class InstallError(Exception):
# this dedicated service user under a root-owned system unit; root never runs a
# file the service user can write.
SERVICE_USER = "oac-node"
+# Core's UID: workspace storage gives sandboxes files owned by it, so the node account must match.
+SERVICE_UID = 65532
SERVICE_HOME = Path("/var/lib/oac-node")
SYSTEM_RECORDS = Path("/etc/oac-node")
SYSTEM_UNITS = Path("/etc/systemd/system")
@@ -1052,6 +1054,17 @@ def account_plan():
raise InstallError("An account named " + SERVICE_USER + " exists but was not created or adopted by this installer "
"(home " + account.pw_dir + ", shell " + account.pw_shell + ", uid " + str(account.pw_uid)
+ ", or groups beyond its own, docker and kvm). Rename or remove it, then rerun." + NOTHING_CHANGED)
+ if account is not None and account.pw_uid != SERVICE_UID:
+ raise InstallError("The " + SERVICE_USER + " account has UID " + str(account.pw_uid) + ", but it must use UID "
+ + str(SERVICE_UID) + " to match Core. Renumber or remove it, then rerun." + NOTHING_CHANGED)
+ if account is None:
+ try:
+ holder = pwd.getpwuid(SERVICE_UID).pw_name
+ except KeyError:
+ holder = None
+ if holder:
+ raise InstallError("UID " + str(SERVICE_UID) + " belongs to the account " + holder + ", but the " + SERVICE_USER
+ + " account must use it to match Core. Remove or renumber that account, then rerun." + NOTHING_CHANGED)
if SERVICE_HOME.is_symlink() or (SERVICE_HOME.exists() and not SERVICE_HOME.is_dir()):
raise InstallError(str(SERVICE_HOME) + " must be a directory, not a symbolic link." + NOTHING_CHANGED)
if SERVICE_HOME.exists() and (account is None or SERVICE_HOME.stat().st_uid != account.pw_uid):
@@ -1067,7 +1080,7 @@ def prepare_account(account, record, group):
# A record of an account that no longer exists is stale; the new account replaces it.
records.unlink(missing_ok=True)
shell = next((path for path in ("/usr/sbin/nologin", "/sbin/nologin") if os.path.exists(path)), "/bin/false")
- checked(["useradd", "--system", "--user-group", "--no-create-home", "--home-dir", str(SERVICE_HOME),
+ checked(["useradd", "--system", "--uid", str(SERVICE_UID), "--user-group", "--no-create-home", "--home-dir", str(SERVICE_HOME),
"--shell", shell, SERVICE_USER], "Cannot create the " + SERVICE_USER + " service user")
account = service_account()
record = {"format": 1, "created": True, "uid": account.pw_uid, "groups_added": []}
diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py
index 01c519aac..dfb9ef4ce 100644
--- a/deploy/node/node_spec.py
+++ b/deploy/node/node_spec.py
@@ -24,7 +24,7 @@ class UnrecordedIdentity(SpecificationError):
# BEGIN GENERATED DEPLOYMENT CONTRACT
# Generated from sandbox/deployment_contract.go; do not edit.
-_CONTRACT = json.loads("{\"workspace_fields\":[\"attachment\",\"user_xattr\",\"capacity_quota\"],\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}")
+_CONTRACT = json.loads("{\"workspace_fields\":[\"attachment\",\"user_xattr\",\"capacity_quota\"],\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192}}},\"minimum_disk\":1024}")
# END GENERATED DEPLOYMENT CONTRACT
@@ -34,6 +34,8 @@ def canonical_spec(provider, specification, validate=True):
raise ValueError("Invalid specification fields")
workspace = specification.get("workspace")
requirements = rules.get("workspace")
+ if validate and requirements and workspace is None:
+ raise ValueError("Workspace storage is required")
if validate and workspace is not None:
if (not isinstance(workspace, dict) or set(workspace) - set(_CONTRACT["workspace_fields"])
or not requirements or workspace.get("attachment") != requirements["attachment"]
diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py
index 9fa3744a6..1302277de 100644
--- a/deploy/node/test_node_install.py
+++ b/deploy/node/test_node_install.py
@@ -99,8 +99,10 @@ def configuration_response(self, request, **kwargs):
raise urllib.error.HTTPError(request.full_url, 401, "", {}, None)
resources = {"cpus": self.cpus, "memory_mib": 6144}
if self.args.provider == "microsandbox":
- resources.update(root_disk_mib=10240, environment_disk_mib=12288)
+ resources.update(root_disk_mib=10240)
spec = {"resources": resources}
+ if self.args.provider == "microsandbox":
+ spec["workspace"] = {"attachment": "host_directory", "user_xattr": True, "capacity_quota": False}
configuration = {"installation_id": self.args.installation_id, "provider": self.args.provider,
"core_url": self.args.core_url, "generation": 1, "specification": spec,
"specification_digest": node_spec.digest(self.args.provider, spec)}
@@ -464,7 +466,7 @@ def run_as(account, function, *arguments):
self.service_steps.append("wait_ready" if function is installer.wait_ready else function.__name__)
with mock.patch.object(installer.Path, "home", return_value=Path(account.pw_dir)):
function(*arguments)
- for patch in (mock.patch.multiple(installer, SERVICE_HOME=service_home, SYSTEM_RECORDS=system / "etc",
+ for patch in (mock.patch.multiple(installer, SERVICE_HOME=service_home, SERVICE_UID=os.getuid(), SYSTEM_RECORDS=system / "etc",
SYSTEM_UNITS=system / "units", SYSTEM_LOCKS=system / "run",
CHILD_DOCKER_CONFIG=system / "docker-config",
SYSTEMD_RUNNING=system / "systemd", SELINUX_ENFORCE=system / "selinux",
@@ -472,7 +474,8 @@ def run_as(account, function, *arguments):
service_account=lambda: self.account),
mock.patch.object(installer.shutil, "which", side_effect=lambda tool: None if tool == "docker" and not self.docker_installed else "/usr/bin/" + tool),
mock.patch.object(installer.grp, "getgrnam", side_effect=lambda name: SimpleNamespace(gr_mem=["oac-node"] if self.joined else [])),
- mock.patch.object(installer.grp, "getgrgid", side_effect=lambda gid: SimpleNamespace(gr_name=self.device_group))):
+ mock.patch.object(installer.grp, "getgrgid", side_effect=lambda gid: SimpleNamespace(gr_name=self.device_group)),
+ mock.patch.object(installer.pwd, "getpwuid", side_effect=KeyError)):
patch.start()
self.addCleanup(patch.stop)
return system
@@ -521,7 +524,7 @@ def test_sudo_mode_prepares_the_host_and_reruns_without_changes(self):
system = self.sudo_host()
installer.install_system(self.args, "synthetic-once-token")
commands = [call for call, _ in self.calls]
- self.assertIn(["useradd", "--system", "--user-group", "--no-create-home", "--home-dir", str(self.home / "service"),
+ self.assertIn(["useradd", "--system", "--uid", str(os.getuid()), "--user-group", "--no-create-home", "--home-dir", str(self.home / "service"),
"--shell", next(path for path in ("/usr/sbin/nologin", "/sbin/nologin", "/bin/false") if os.path.exists(path) or path == "/bin/false"),
"oac-node"], commands)
self.assertTrue(any(call[:3] == ["usermod", "--append", "--groups"] for call in commands))
@@ -739,6 +742,23 @@ def test_uninstall_keeps_a_store_it_did_not_create(self):
self.assertIn("Kept the microsandbox store", self.uninstall_output(False))
self.assertTrue(store.is_dir())
+ def test_sudo_mode_refuses_when_another_account_holds_core_uid(self):
+ self.sudo_host()
+ with mock.patch.object(installer.pwd, "getpwuid", return_value=SimpleNamespace(pw_name="nonroot")):
+ with self.assertRaisesRegex(installer.InstallError, "UID [0-9]+ belongs to the account nonroot.*Nothing was changed"):
+ installer.install_system(self.args, "synthetic-once-token")
+ self.assertFalse([call for call, _ in self.calls if call[:1] in (["useradd"], ["usermod"], ["systemctl"])])
+
+ def test_sudo_mode_refuses_an_existing_account_with_another_uid(self):
+ self.sudo_host()
+ (self.home / "service").mkdir()
+ self.account = SimpleNamespace(pw_name="oac-node", pw_uid=os.getuid() + 1, pw_gid=os.getgid(),
+ pw_dir=str(self.home / "service"), pw_shell="/usr/sbin/nologin")
+ with mock.patch.object(installer.os, "getgrouplist", return_value=[os.getgid()]):
+ with self.assertRaisesRegex(installer.InstallError, "must use UID [0-9]+ to match Core.*Nothing was changed"):
+ installer.install_system(self.args, "synthetic-once-token")
+ self.assertFalse([call for call, _ in self.calls if call[:1] in (["useradd"], ["usermod"], ["systemctl"])])
+
def test_uninstall_leaves_an_adopted_account_as_found(self):
system = self.sudo_host()
(self.home / "service").mkdir()
diff --git a/docs/configuration.md b/docs/configuration.md
index 02294cbc2..5d84f9394 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -136,7 +136,7 @@ The Compose project is named `oac-<10 hex digits>`. Its services are `init`, `da
## Independent workspace storage {#independent-workspace-storage}
-The supported independent filesystem combination is microsandbox with the [kernel NFS adapter](./workspace-provider.md#kernel-nfs-adapter). Select it in **Settings → Workspace storage** (`/core/v1/workspace-storage`, see the [Core administration API](../contracts/agents-api/admin-api.md#workspace-storage)); the selection and its tenant capacity apply to new workspaces, and existing workspaces keep their configuration. [Set up workspace storage](./getting-started/workspace-storage.md) covers the NFS export, the `oac-node` account, the Compose mount, the selection payloads and the quota mode storage server.
+microsandbox requires independent workspace storage, and the only supported combination is with the [kernel NFS adapter](./workspace-provider.md#kernel-nfs-adapter). Select it in **Settings → Workspace storage** (`/core/v1/workspace-storage`, see the [Core administration API](../contracts/agents-api/admin-api.md#workspace-storage)); the selection and its tenant capacity apply to new workspaces, and existing workspaces keep their configuration. [Set up workspace storage](./getting-started/workspace-storage.md) covers the NFS export, the Compose mount, the selection payloads and the quota mode storage server.
## Appendix: Core environment without the installer
diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md
index 610c3e307..5e2b12f69 100644
--- a/docs/getting-started/nodes.md
+++ b/docs/getting-started/nodes.md
@@ -12,7 +12,7 @@ Core places each new Session on a node with free capacity; the node creates the
- **Core has a public URL** that the host and its sandboxes can reach. Nodes download from Core's console and connect to Core at `public_url`. Until it is set, Add node says *Set a public address other machines can reach before adding nodes*; see [Configure the public address](./public-address.md).
- **The console can serve the node files.** Nodes download their Runtime and provider files from the console, which redirects to the release for files it does not hold, and check each file's size and SHA-256 against the release manifest. Node hosts therefore need access to the release. Without the files, Add node says *This console has no node files for …*.
- **The host meets the requirements below.**
-- **For independent workspace storage,** the NFS mount and service account are ready on this host; see [Set up workspace storage](./workspace-storage.md). The node receives the selected immutable filesystem configuration with each binding; do not author a separate node storage setting.
+- **For microsandbox,** the NFS mount for workspace storage is ready on this host; see [Set up workspace storage](./workspace-storage.md). The node receives the selected immutable filesystem configuration with each binding; do not author a separate node storage setting.
- **For checkpoint recovery across microsandbox nodes,** see [Checkpoint recovery across nodes](#checkpoint-recovery-across-nodes).
The Core host joins like any other host: to run sandboxes on it, add it as a node.
@@ -62,7 +62,7 @@ The node service keeps the proxy settings of the latest run, so it reaches Core
| Item | Detail |
| --- | --- |
-| Service user | System user `oac-node`, home `/var/lib/oac-node`, no login shell. An existing account with that home and a nologin shell is adopted; any other account named `oac-node` is refused |
+| Service user | System user `oac-node` with UID 65532, the UID of Core's service, home `/var/lib/oac-node` and no login shell. An existing account with that UID, that home and a nologin shell is adopted; any other account named `oac-node` is refused. If another account holds UID 65532, the installer stops and changes nothing |
| Group | The group that owns `/var/run/docker.sock` (Docker) or `/dev/kvm` (microsandbox): `docker` or `kvm`, nothing else |
| Service | `/etc/systemd/system/oac-node-.service`, a root-owned system unit with `User=oac-node`, enabled at boot. It restarts every 5 seconds while Core is unreachable and stops for good once Core no longer accepts the node |
| Node state | `/var/lib/oac-node/.oac/nodes//`: identity, configuration, node files. microsandbox keeps its images and sandboxes under `/var/lib/oac-node/.oac/m/` |
diff --git a/docs/getting-started/sandbox-backends.md b/docs/getting-started/sandbox-backends.md
index a66720331..acf1069d4 100644
--- a/docs/getting-started/sandbox-backends.md
+++ b/docs/getting-started/sandbox-backends.md
@@ -15,8 +15,8 @@ The installer selects no backend. One installation uses one backend. To change i
| You provide | Linux amd64 hosts with systemd and `/dev/kvm` | Linux amd64 hosts with systemd and rootful Docker Engine | An E2B API key and a template; a public HTTPS URL for Core |
| Isolation | Each sandbox has its own kernel | Containers share the node's kernel | E2B's |
| Idle sandboxes suspend and resume | Yes, with checkpoints | No | No |
-| Independent workspace storage | Optional, on NFS | No | No |
-| Disk limits | Root and data disks | None | Set by the template |
+| Workspace storage | Required, on NFS | Own disk | Own disk |
+| Disk limits | Root disk and workspace quota | None | Set by the template |
The field-level differences are in the [deployment contract](../../contracts/agents-api/sandbox-deployment.md#what-each-field-means-per-sandbox-provider). Host requirements for nodes are in [Add and manage nodes](./nodes.md#host-requirements).
@@ -30,7 +30,7 @@ Sandbox nodes run on Linux amd64. When Core runs on macOS or Windows, connect a
2. Choose the backend and the sandbox size. Web proposes the [default size the Provider declares](../sandbox-provider.md#register-the-provider-kind) as **Standard**.
3. Choose **Save configuration**. The choice is stored in Core's database.
-For **Own machines**, continue with [Add and manage nodes](./nodes.md). To keep workspaces off the nodes, first [Set up workspace storage](./workspace-storage.md). For checkpoint recovery across microsandbox nodes, see [Checkpoint recovery across nodes](./nodes.md#checkpoint-recovery-across-nodes).
+For **Own machines**, continue with [Add and manage nodes](./nodes.md). For microsandbox, first [Set up workspace storage](./workspace-storage.md). For checkpoint recovery across microsandbox nodes, see [Checkpoint recovery across nodes](./nodes.md#checkpoint-recovery-across-nodes).
## Use E2B
diff --git a/docs/getting-started/workspace-storage.md b/docs/getting-started/workspace-storage.md
index f51ba6bcf..db15e059e 100644
--- a/docs/getting-started/workspace-storage.md
+++ b/docs/getting-started/workspace-storage.md
@@ -2,14 +2,13 @@
title: "Set up workspace storage"
---
-Keep Session workspaces on an NFS filesystem outside the sandbox nodes, with or without per-workspace quotas. This guide is for operators who run microsandbox and want workspaces to outlive their compute.
+Keep Session workspaces on an NFS filesystem outside the sandbox nodes, with or without per-workspace quotas. microsandbox runs every Environment on this storage, so select it before you configure the microsandbox backend.
-The supported combination is microsandbox with the [kernel NFS adapter](../workspace-provider.md#kernel-nfs-adapter). Docker and E2B do not support independent workspace storage; see [Choose a sandbox backend](./sandbox-backends.md). The [Configuration reference](../configuration.md#independent-workspace-storage) lists where the setting lives.
+The supported combination is microsandbox with the [kernel NFS adapter](../workspace-provider.md#kernel-nfs-adapter). Docker and E2B keep their own disks and do not support it; see [Choose a sandbox backend](./sandbox-backends.md). The [Configuration reference](../configuration.md#independent-workspace-storage) lists where the setting lives.
```mermaid
flowchart LR
- A[Prepare NFS export] --> B[Prepare oac-node]
- B --> C[Mount into Core]
+ A[Prepare NFS export] --> C[Mount into Core]
C --> D[Select the storage]
D --> E[Configure microsandbox]
E --> F[Add nodes]
@@ -25,12 +24,6 @@ Mount the same NFSv4.2 export on the Linux Core host and on every participating
Prepare the namespace and service principals according to the adapter's [ownership requirements](../workspace-provider.md#kernel-nfs-adapter).
-## Prepare the node account
-
-Core's Compose service already runs as UID 65532. Before adding a node, prepare `oac-node` with the same UID, home `/var/lib/oac-node`, a nologin shell and a nonzero primary group. Its supplementary groups may contain only its primary group, `docker` and `kvm`; an existing home must belong to that account. The node installer adopts this account. Without a precreated account it allocates a system UID, which need not match Core.
-
-Resolve an existing UID or account conflict as a host administration task before enrollment. Changing a running account's UID is not part of storage setup.
-
## Mount the export into Core
Add a read-write bind mount to the existing `core` service's `volumes` through the deployment platform's Compose customization, for example in `compose.override.yaml`. Keep the existing service volumes and user unchanged.
@@ -74,7 +67,7 @@ Selection runs the adapter's availability, ownership and xattr checks in Core. I
## Configure microsandbox and add nodes
-Create or update the microsandbox deployment through the [sandbox deployment API](../../contracts/agents-api/sandbox-deployment.md#routes), keeping the required compute resources, Runtime and provider configuration. `resources.environment_disk_mib` is each new workspace's capacity: `0` without quota mode, where a positive value is rejected, and at least 1024 MiB in quota mode, where `0` is refused at workspace admission. Existing workspaces keep the capacity they were admitted with when the deployment changes.
+Create or update the microsandbox deployment through the [sandbox deployment API](../../contracts/agents-api/sandbox-deployment.md#routes), keeping the required compute resources, Runtime and provider configuration. `resources.environment_disk_mib` is each new workspace's capacity quota request: `0` (the default) without quota mode, where a positive value is rejected, and at least 1024 MiB in quota mode, where `0` is refused at workspace admission. A deployment saved before storage is selected returns `workspace_storage_required`. Existing workspaces keep the capacity they were admitted with when the deployment changes.
Deployment workspace requirements and each Session's attachment come from the selected database configuration. The selected configuration is the only storage setting.
diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md
index 88b713fdb..e5845c6cf 100644
--- a/docs/runtime-protocol.md
+++ b/docs/runtime-protocol.md
@@ -70,7 +70,7 @@ A declaration describes what the Runtime can do. Core admits a public feature on
| `local_environment`, `workspace_read_preparation`, `workspace_output_export` | The Environment type is `openai_hosted` or `self_hosted` |
| `workspace_read_preparation` | An idle Files directory read needs a read-only preparation |
| `native_session_recovery` | A Session with a started Turn has no recorded native Session ID |
-| `retained_native_history` | The Environment uses an external workspace binding; owned workspace storage does not require this capability |
+| `retained_native_history` | The Environment uses an external workspace binding; a Provider's own disk does not require this capability |
| `structured_output` and `message_items` | The Agent requests `json_schema` output. A Runtime that declares `message_items` tags text deltas with the native item ID and reports message start, completion, phase and the completion text in `output_message` frames |
| `subagent_observations` | `multi_agent.enabled` is true |
| `subagent_control` | `multi_agent.enabled` is false |
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index 2290bbd56..9dada3267 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -52,7 +52,7 @@ Isolation belongs to the provider's infrastructure, not the daemon; see [Runtime
A backend without a native renewable lease, such as Docker, still keeps Core's hosted expiry and cleanup requirements. Every adapter implements `RunCommand` and its tests exercise it, but Core's orchestration does not call it; only the node transport forwards it. Confidential command input travels in `Command.Stdin`, never in arguments or logs, and the result keeps byte order, bounded output and the actual exit status.
-`Bootstrap.Workspace` and `ResumeRequest.Workspace` optionally carry an external workspace binding; the Provider obligations for it are in [Attachment](./workspace-provider.md#attachment).
+`Bootstrap.Workspace` and `ResumeRequest.Workspace` carry an external workspace binding exactly when the Provider's `DeploymentPolicy.Workspace` is declared; the Provider obligations for it are in [Attachment](./workspace-provider.md#attachment).
### Explicit operation contracts
@@ -124,7 +124,7 @@ Hosted and self-hosted Environments use the same Runtime preparation; a provider
## Register the provider kind
-`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), its `sandbox.DeploymentPolicy` (disk limits and the default size setup proposes), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading.
+`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), its `sandbox.DeploymentPolicy` (disk limits, the optional external workspace requirement and the default size setup proposes), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading.
A new provider takes these steps:
diff --git a/docs/workspace-provider.md b/docs/workspace-provider.md
index e35713d40..2cb4b95ba 100644
--- a/docs/workspace-provider.md
+++ b/docs/workspace-provider.md
@@ -15,7 +15,7 @@ Core selects and validates the combination of a Sandbox Provider and a filesyste
| Sandbox Provider | External workspace storage |
| --- | --- |
-| microsandbox | Supported (`host_directory`, `user_xattr`) |
+| microsandbox | Required (`host_directory`, `user_xattr`) |
| Docker, E2B | Not supported; the Provider's own Environment disk |
## Implement an adapter
@@ -96,11 +96,11 @@ Workspace storage is retained with its Session until explicit Session deletion.
## Attachment
-`Bootstrap.Workspace` and `ResumeRequest.Workspace` optionally carry a [workspace filesystem binding](#identity-and-configuration). Absence selects the Provider's owned Environment disk; presence selects the exact external object and never permits a fallback to an owned disk. Providers validate the tenant and Environment against the binding and its configuration receipt before resolving it. `LocalOptions.Workspace` injects the filesystem resolver; Sandbox Providers never interpret filesystem adapter parameters or native receipts.
+`Bootstrap.Workspace` and `ResumeRequest.Workspace` optionally carry a [workspace filesystem binding](#identity-and-configuration). A Provider whose policy declares workspace storage requires the binding and returns `ErrInvalid` without it; a Provider without that declaration keeps its own Environment disk and rejects a binding. Presence selects the exact external object and never permits a fallback to another disk. Providers validate the tenant and Environment against the binding and its configuration receipt before resolving it. `LocalOptions.Workspace` injects the filesystem resolver; Sandbox Providers never interpret filesystem adapter parameters or native receipts.
-`DeploymentPolicy.Workspace` declares supported attachment requirements; absence means external storage is unsupported. The derived immutable `DeploymentSpec.Workspace` receipt selects the generation mode and capabilities; its [deployment workflow](../contracts/agents-api/sandbox-deployment.md#resources) never copies filesystem configuration into the generation. Microsandbox requires `host_directory` and `user_xattr`; Docker and E2B reject external attachments. `ValidateWorkspacePolicy` uses the filesystem protocol's combination validator: an external filesystem without an enforced quota rejects a positive `EnvironmentDiskMiB`, while zero requests no quota. Root disk capacity is still required. Without an external declaration, the existing owned disk bounds apply.
+`DeploymentPolicy.Workspace` present means the Provider runs every Environment on external storage with the declared attachment requirements; absent means the Provider's own disk and no external storage. The derived immutable `DeploymentSpec.Workspace` receipt records the selected capabilities and is always present for such a Provider; a deployment with no storage selected returns `ErrWorkspaceRequired`, which Core reports as `workspace_storage_required`. The [deployment workflow](../contracts/agents-api/sandbox-deployment.md#resources) never copies filesystem configuration into the generation. Microsandbox requires `host_directory` and `user_xattr`; Docker and E2B reject external attachments. Validation uses the filesystem protocol's combination validator: `EnvironmentDiskMiB` is only a capacity quota request, valid when the storage declares `capacity_quota`, and its default is zero, which requests no quota. Root disk capacity is still required.
-Microsandbox resolves each Create and Resume binding, including observation of an interrupted restore, and passes only the local directory and immutable ObjectID to its private helper. The helper binds the whole `/environment`; its retained contents and private native history follow the [workspace filesystem scope](#lifetime-and-filesystem-scope) and [Runtime resource directories](configuration.md#runtime-resource-directories). Private HOME, credentials and the compute root are not a durable history store. Explicit mode, object and path labels qualify native resources; mount shape alone never selects a mode. Checkpoint restore verifies the snapshot's object, remaps `/environment` through SDK `Volumes`, requires strict external mount policy and rejects every restore warning. Partial restore errors retain the exact target identity for cleanup and never establish readiness. Unknown outcomes remain observations of the original operation. Native Bind checkpoint and guest-local flock continuity do not establish fencing against an unknown writer.
+Microsandbox resolves each Create and Resume binding, including observation of an interrupted restore, and passes only the local directory and immutable ObjectID to its private helper. The helper binds the whole `/environment`; its retained contents and private native history follow the [workspace filesystem scope](#lifetime-and-filesystem-scope) and [Runtime resource directories](configuration.md#runtime-resource-directories). Private HOME, credentials and the compute root are not a durable history store. Object and path labels qualify native resources. Checkpoint restore verifies the snapshot's object, remaps `/environment` through SDK `Volumes`, requires strict external mount policy and rejects every restore warning. Partial restore errors retain the exact target identity for cleanup and never establish readiness. Unknown outcomes remain observations of the original operation. Native Bind checkpoint and guest-local flock continuity do not establish fencing against an unknown writer.
## Kernel NFS adapter
@@ -137,7 +137,7 @@ stateDiagram-v2
NFS storage does not supply compute fencing or automatic distributed failover. Guest `flock` is not a cross-VM writer lock. The single-writer and explicit-deletion requirements above remain mandatory. Confirmed release can permit a fresh VM on another compatible node to attach the same object, but an offline node or an unknown Create, Kill or snapshot-cleanup result never proves that the old writer stopped. Native history and database behavior require qualification of the selected Runtime, Harness and filesystem combination; file persistence alone is not a guarantee of native Session recovery, cross-node memory restore or arbitrary crash durability.
-Core requires the selected Harness profile to support `retained_native_history` when the selected deployment generation uses external workspace storage. Creation validates that generation while holding the deployment lock, before committing the Session or compute reservation. Existing Sessions use their immutable workspace binding for Runtime admission and the final execution check. Owned workspace storage does not require this capability.
+Core requires the selected Harness profile to support `retained_native_history` when the selected deployment generation uses external workspace storage, which is every microsandbox generation. Creation validates that generation while holding the deployment lock, before committing the Session or compute reservation. Existing Sessions use their immutable workspace binding for Runtime admission and the final execution check. A Provider with its own disk does not require this capability.
## Conformance
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md
index 0fca20108..d68e58b04 100644
--- a/docs/zh/configuration.md
+++ b/docs/zh/configuration.md
@@ -1,7 +1,7 @@
---
title: "配置参考"
source: docs/configuration.md
-source_hash: d3d85be9a8784413ccb107ff8111a15647fd573f31bd66525a322202332e0fb7
+source_hash: 5519609be1b60dbd8c1b3aaac6aa8500f3907bebdba50a9e2ce6cb4ebf928b13
---
Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类:
@@ -140,7 +140,7 @@ Compose 项目名为 `oac-<10 hex digits>`,服务包括 `init`、`database`、
## 独立工作区存储 {#independent-workspace-storage}
-支持的独立文件系统组合是 microsandbox 与[内核 NFS 适配器](./workspace-provider.md#kernel-nfs-adapter)。在 **设置 → 工作区存储** 中选择它(`/core/v1/workspace-storage`,参阅 [Core 管理 API](../../contracts/agents-api/zh/admin-api.md#workspace-storage));该选择及其租户容量适用于新工作区,现有工作区保留自身配置。[设置工作区存储](getting-started/workspace-storage.md)涵盖 NFS 导出、`oac-node` 账户、Compose 挂载、选择负载和配额模式存储服务器。
+microsandbox 要求使用独立工作区存储,唯一支持的组合是与[内核 NFS 适配器](./workspace-provider.md#kernel-nfs-adapter)。在 **设置 → 工作区存储** 中选择它(`/core/v1/workspace-storage`,参阅 [Core 管理 API](../../contracts/agents-api/zh/admin-api.md#workspace-storage));该选择及其租户容量适用于新工作区,现有工作区保留自身配置。[设置工作区存储](getting-started/workspace-storage.md)涵盖 NFS 导出、Compose 挂载、选择负载和配额模式存储服务器。
## 附录:没有安装程序时的 Core 环境 {#appendix-core-environment-without-the-installer}
diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md
index 9d4e7ff0d..e59860b63 100644
--- a/docs/zh/getting-started/nodes.md
+++ b/docs/zh/getting-started/nodes.md
@@ -1,7 +1,7 @@
---
title: "添加和管理节点"
source: docs/getting-started/nodes.md
-source_hash: 65d9798a2843a67c27d8b4e4a86cc4977be1ac41b798e32b6b8dde6059958640
+source_hash: fef6bd64da44efaa4ab1943901667c68c749790c36974ffb15b1b2845d62a7eb
---
节点是一台 Linux 主机,为 Core 托管 Session 运行沙箱。本指南面向在沙箱提供商(参阅[选择沙箱提供商](./sandbox-backends.md))为 microsandbox 或 Docker 时添加、修改和移除节点的运维人员;E2B 不需要节点。
@@ -14,7 +14,7 @@ Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙
- **Core 已有主机及沙箱可访问的公开 URL。** 节点从 Core 控制台下载文件,并通过 `public_url` 连接 Core。设置前,**添加节点** 显示 *添加节点前,请先设置其他机器能访问的公开地址。*参阅[配置公开地址](./public-address.md)。
- **控制台能提供节点文件。** 节点从控制台下载 Runtime 和提供商文件;控制台缺少文件时重定向到发行下载地址。节点依据发行清单检查各文件的大小和 SHA-256。因此节点主机需要能访问发行下载地址。缺少文件时,**添加节点** 显示 *这个控制台没有 … 的节点文件。*
- **主机满足下面的要求。**
-- **使用独立工作区存储时,** 此主机上的 NFS 挂载和服务账户已就绪;参阅[设置工作区存储](./workspace-storage.md)。节点随每次 binding 接收所选不可变文件系统配置;不要单独编写节点存储设置。
+- **使用 microsandbox 时,** 此主机上用于工作区存储的 NFS 挂载已就绪;参阅[设置工作区存储](./workspace-storage.md)。节点随每次 binding 接收所选不可变文件系统配置;不要单独编写节点存储设置。
- **microsandbox 跨节点检查点恢复,** 参阅[跨节点的检查点恢复](#checkpoint-recovery-across-nodes)。
Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添加为节点。
@@ -64,7 +64,7 @@ Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添
| 项目 | 详情 |
| --- | --- |
-| 服务用户 | 系统用户 `oac-node`,主目录 `/var/lib/oac-node`,无登录 shell。已有账号的主目录相同且 shell 为 nologin 时会复用;其他名为 `oac-node` 的账号会被拒绝 |
+| 服务用户 | 系统用户 `oac-node`,UID 为 65532(与 Core 服务相同),主目录 `/var/lib/oac-node`,无登录 shell。已有账号的 UID、主目录都相同且 shell 为 nologin 时会复用;其他名为 `oac-node` 的账号会被拒绝。如果其他账号占用 UID 65532,安装程序会停止且不做任何更改 |
| 用户组 | 拥有 `/var/run/docker.sock`(Docker)或 `/dev/kvm`(microsandbox)的组:仅 `docker` 或 `kvm` |
| 服务 | `/etc/systemd/system/oac-node-.service`,root 所属的系统单元,使用 `User=oac-node`,开机启用。Core 无法访问时每 5 秒重启;Core 不再接受节点后永久停止 |
| 节点状态 | `/var/lib/oac-node/.oac/nodes//`:身份、配置和节点文件。microsandbox 在 `/var/lib/oac-node/.oac/m/` 保存镜像和沙箱 |
diff --git a/docs/zh/getting-started/sandbox-backends.md b/docs/zh/getting-started/sandbox-backends.md
index 731a25baf..d7310d808 100644
--- a/docs/zh/getting-started/sandbox-backends.md
+++ b/docs/zh/getting-started/sandbox-backends.md
@@ -1,7 +1,7 @@
---
title: "选择沙箱提供商"
source: docs/getting-started/sandbox-backends.md
-source_hash: 5b374c1a7387110371ca16d4817ee52da4e23794e15edd9530599bb934e02afd
+source_hash: 8dd1818c9061d0e29aee5949c189772aba6ffe72a9d984c0ed8d226571754d51
---
比较 microsandbox、Docker 和 E2B,然后在 Web 中选择一个。本指南面向已完成[安装 Core 和 Web](./install.md)、需要为 Core 托管的 Session 提供计算资源的运维人员。
@@ -17,8 +17,8 @@ source_hash: 5b374c1a7387110371ca16d4817ee52da4e23794e15edd9530599bb934e02afd
| 你需要提供 | 带 systemd 和 `/dev/kvm` 的 Linux amd64 主机 | 带 systemd 和 rootful Docker Engine 的 Linux amd64 主机 | E2B API 密钥和模板;Core 的公共 HTTPS URL |
| 隔离 | 每个沙箱有独立内核 | 容器共享节点内核 | 由 E2B 提供 |
| 空闲沙箱可挂起和恢复 | 是,通过检查点 | 否 | 否 |
-| 独立工作区存储 | 可选,使用 NFS | 否 | 否 |
-| 磁盘限制 | 根磁盘和数据磁盘 | 无 | 由模板设置 |
+| 工作区存储 | 必需,使用 NFS | 自有磁盘 | 自有磁盘 |
+| 磁盘限制 | 根磁盘和工作区配额 | 无 | 由模板设置 |
字段级差异见[部署契约](../../../contracts/agents-api/zh/sandbox-deployment.md#what-each-field-means-per-sandbox-provider)。节点的主机要求见[添加和管理节点](./nodes.md#host-requirements)。
@@ -32,7 +32,7 @@ Docker 节点在其主机上[等同于 root](./nodes.md#what-the-installer-sets-
2. 选择提供商和沙箱规格。Web 会把[提供商声明的默认大小](../sandbox-provider.md#register-the-provider-kind)推荐为 **标准**。
3. 选择 **保存配置**。该选择保存在 Core 的数据库中。
-选择 **自有机器** 后,继续阅读[添加和管理节点](./nodes.md)。要让工作区不保存在节点上,请先[设置工作区存储](./workspace-storage.md)。microsandbox 节点间的检查点恢复,参阅[跨节点恢复检查点](./nodes.md#checkpoint-recovery-across-nodes)。
+选择 **自有机器** 后,继续阅读[添加和管理节点](./nodes.md)。使用 microsandbox 时,请先[设置工作区存储](./workspace-storage.md)。microsandbox 节点间的检查点恢复,参阅[跨节点恢复检查点](./nodes.md#checkpoint-recovery-across-nodes)。
## 使用 E2B {#use-e2b}
diff --git a/docs/zh/getting-started/workspace-storage.md b/docs/zh/getting-started/workspace-storage.md
index 7120b432f..511b98aa9 100644
--- a/docs/zh/getting-started/workspace-storage.md
+++ b/docs/zh/getting-started/workspace-storage.md
@@ -1,17 +1,16 @@
---
title: "设置工作区存储"
source: docs/getting-started/workspace-storage.md
-source_hash: c236fdfcbcdcf01856361813476c7d6b1f6e5de2e28022ac5d3b2b3bb49c2a1b
+source_hash: 3e1ff9740cc093133658186a8562250f183963f83c657d5413d64058975f75df
---
-将 Session 工作区保存在沙箱节点之外的 NFS 文件系统上,可选择是否设置每个工作区的配额。本指南面向运行 microsandbox、希望工作区比计算资源存活更久的运维人员。
+将 Session 工作区保存在沙箱节点之外的 NFS 文件系统上,可选择是否设置每个工作区的配额。microsandbox 的每个 Environment 都运行在此存储上,因此请在配置 microsandbox 后端之前先选择它。
-支持的组合是 microsandbox 与[内核 NFS 适配器](../workspace-provider.md#kernel-nfs-adapter)。Docker 和 E2B 不支持独立工作区存储;参阅[选择沙箱提供商](./sandbox-backends.md)。[配置参考](../configuration.md#independent-workspace-storage)列出了该设置所在的位置。
+支持的组合是 microsandbox 与[内核 NFS 适配器](../workspace-provider.md#kernel-nfs-adapter)。Docker 和 E2B 使用自有磁盘,不支持独立工作区存储;参阅[选择沙箱提供商](./sandbox-backends.md)。[配置参考](../configuration.md#independent-workspace-storage)列出了该设置所在的位置。
```mermaid
flowchart LR
- A[Prepare NFS export] --> B[Prepare oac-node]
- B --> C[Mount into Core]
+ A[Prepare NFS export] --> C[Mount into Core]
C --> D[Select the storage]
D --> E[Configure microsandbox]
E --> F[Add nodes]
@@ -27,12 +26,6 @@ flowchart LR
按照适配器的[所有权要求](../workspace-provider.md#kernel-nfs-adapter)准备命名空间和服务身份。
-## 准备节点账户 {#prepare-the-node-account}
-
-Core 的 Compose 服务已使用 UID 65532 运行。添加节点前,创建同 UID 的 `oac-node`,主目录为 `/var/lib/oac-node`,使用 nologin shell 和非零主组。附加组只能包含其主组、`docker` 和 `kvm`;已有主目录必须属于该账户。节点安装程序会接管此账户。如果没有预创建账户,安装程序分配的系统 UID 不一定与 Core 一致。
-
-注册前应通过主机管理流程解决已有 UID 或账户冲突;修改运行中账户的 UID 不属于存储配置步骤。
-
## 将导出挂载到 Core {#mount-the-export-into-core}
通过部署平台的 Compose 自定义能力(例如 `compose.override.yaml`),在现有 `core` 服务的 `volumes` 中添加可读写绑定挂载。保留现有服务卷和用户。
@@ -76,7 +69,7 @@ services:
## 配置 microsandbox 并添加节点 {#configure-microsandbox-and-add-nodes}
-通过[沙箱部署 API](../../../contracts/agents-api/zh/sandbox-deployment.md#routes)创建或更新 microsandbox 部署,并保留所需计算资源、Runtime 和提供商配置。`resources.environment_disk_mib` 是每个新工作区的容量:非配额模式下为 `0`,正数会被拒绝;配额模式下至少为 1024 MiB,`0` 会在工作区准入时被拒绝。部署变更时,既有工作区保持其获准时的容量。
+通过[沙箱部署 API](../../../contracts/agents-api/zh/sandbox-deployment.md#routes)创建或更新 microsandbox 部署,并保留所需计算资源、Runtime 和提供商配置。`resources.environment_disk_mib` 是每个新工作区的容量配额请求:非配额模式下为 `0`(默认值),正数会被拒绝;配额模式下至少为 1024 MiB,`0` 会在工作区准入时被拒绝。选择存储之前保存的部署会返回 `workspace_storage_required`。部署变更时,既有工作区保持其获准时的容量。
部署的工作区要求和每个 Session 的挂载凭据均从已选数据库配置派生。已选配置是唯一的存储设置。
diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md
index 3c570fe82..d9da4fa49 100644
--- a/docs/zh/runtime-protocol.md
+++ b/docs/zh/runtime-protocol.md
@@ -1,7 +1,7 @@
---
title: "Core–Runtime 协议"
source: docs/runtime-protocol.md
-source_hash: 8764ef0b2f84de45e65f77d66e2f4a76f4e6771543d0ec9b63b04b81a7ee17d4
+source_hash: 68d35aa375b6c371975c619712f65970dc0055b49e4362c271f835422779eea1
---
Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。
@@ -72,7 +72,7 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 `
| `local_environment`, `workspace_read_preparation`, `workspace_output_export` | Environment 类型为 `openai_hosted` 或 `self_hosted` |
| `workspace_read_preparation` | 空闲 Files 目录读取需要只读 preparation |
| `native_session_recovery` | Session 已启动过 Turn,但未记录原生 Session ID |
-| `retained_native_history` | Environment 使用外部工作区绑定;自带工作区存储不要求此能力 |
+| `retained_native_history` | Environment 使用外部工作区绑定;提供商自有磁盘不要求此能力 |
| `structured_output` 和 `message_items` | Agent 请求 `json_schema` 输出。声明 `message_items` 的 Runtime 会给文本 delta 标注原生 item ID,并通过 `output_message` frame 报告消息开始、完成、phase 和完成文本 |
| `subagent_observations` | `multi_agent.enabled` 为 true |
| `subagent_control` | `multi_agent.enabled` 为 false |
diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md
index 09ff9bbde..4886b9aaa 100644
--- a/docs/zh/sandbox-provider.md
+++ b/docs/zh/sandbox-provider.md
@@ -1,7 +1,7 @@
---
title: "沙箱提供商协议"
source: docs/sandbox-provider.md
-source_hash: 2f9163d7e65eb780ab057b590d6ceca0bb24b5cd2a2376a13f440a53e9dd64e2
+source_hash: 9d36546c75d10805c104092536d8bb13a87b887bef1573f2f4fd5ee94d1673c7
---
沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。
@@ -54,7 +54,7 @@ source_hash: 2f9163d7e65eb780ab057b590d6ceca0bb24b5cd2a2376a13f440a53e9dd64e2
Docker 等没有原生可续期租约的 backend 仍遵守 Core 的 hosted expiry 和 cleanup 要求。每个 adapter 都实现 `RunCommand`,测试也执行它,但 Core 编排不调用它;只有 node transport 转发。机密命令输入通过 `Command.Stdin` 传输,不放在参数或日志中;结果保留字节顺序、有界输出和实际退出状态。
-`Bootstrap.Workspace` 和 `ResumeRequest.Workspace` 可携带外部工作区绑定;提供商对此的义务见[挂载](workspace-provider.md#attachment)。
+`Bootstrap.Workspace` 和 `ResumeRequest.Workspace` 仅当提供商的 `DeploymentPolicy.Workspace` 已声明时才携带外部工作区绑定;提供商对此的义务见[挂载](workspace-provider.md#attachment)。
### 明确的操作契约 {#explicit-operation-contracts}
@@ -126,7 +126,7 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity`
## 注册提供商 kind {#register-the-provider-kind}
-`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、`sandbox.DeploymentPolicy`(磁盘限制,以及 setup 推荐的默认大小)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。
+`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、`sandbox.DeploymentPolicy`(磁盘限制、可选的外部工作区要求,以及 setup 推荐的默认大小)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。
新提供商执行以下步骤:
diff --git a/docs/zh/workspace-provider.md b/docs/zh/workspace-provider.md
index 440141f84..524801276 100644
--- a/docs/zh/workspace-provider.md
+++ b/docs/zh/workspace-provider.md
@@ -1,7 +1,7 @@
---
title: 工作区文件系统协议
source: docs/workspace-provider.md
-source_hash: 7d20a39d5ea33ae1874c4aee79257b4e80bd806727375b9664535c0172dc102d
+source_hash: 2afa36a287c1b4268fef271f18ae094f74d1fc61d8c841bfbe21e9dbabd86cd6
---
工作区文件系统协议连接 Core 和沙箱节点与拥有 Environment 持久存储的适配器。Core 创建、观察和删除存储对象;节点将对象解析为主机目录,由其[沙箱提供商协议](sandbox-provider.md)挂载到计算资源。本文面向文件系统适配器和沙箱提供商的作者。
@@ -17,7 +17,7 @@ Core 在分配任一资源前,选择并验证沙箱提供商与文件系统适
| 沙箱提供商 | 外部工作区存储 |
| --- | --- |
-| microsandbox | 支持(`host_directory`、`user_xattr`) |
+| microsandbox | 必需(`host_directory`、`user_xattr`) |
| Docker、E2B | 不支持;使用提供商自有的 Environment 磁盘 |
## 实现适配器 {#implement-an-adapter}
@@ -97,11 +97,11 @@ Core 在分配任一资源前,选择并验证沙箱提供商与文件系统适
## 挂载 {#attachment}
-`Bootstrap.Workspace` 和 `ResumeRequest.Workspace` 可携带[工作区文件系统绑定](#identity-and-configuration)。未提供时使用提供商自有的 Environment 磁盘;提供时选择精确的外部对象,不得回退到自有磁盘。提供商在解析前校验租户、Environment、绑定及其配置回执。`LocalOptions.Workspace` 注入文件系统解析器;沙箱提供商不解释文件系统适配器参数或原生回执。
+`Bootstrap.Workspace` 和 `ResumeRequest.Workspace` 携带[工作区文件系统绑定](#identity-and-configuration)。策略声明了工作区存储的提供商要求提供绑定,缺失时返回 `ErrInvalid`;未声明的提供商保留自有 Environment 磁盘并拒绝绑定。提供时选择精确的外部对象,不得回退到其他磁盘。提供商在解析前校验租户、Environment、绑定及其配置回执。`LocalOptions.Workspace` 注入文件系统解析器;沙箱提供商不解释文件系统适配器参数或原生回执。
-`DeploymentPolicy.Workspace` 声明挂载要求;未声明表示不支持外部存储。派生的不可变 `DeploymentSpec.Workspace` 回执选择代次的模式和能力;[部署流程](../../contracts/agents-api/zh/sandbox-deployment.md#resources)不会将文件系统配置复制进代次。Microsandbox 要求 `host_directory` 和 `user_xattr`;Docker 和 E2B 拒绝外部挂载。`ValidateWorkspacePolicy` 使用文件系统协议的组合校验器:不强制容量配额的外部文件系统拒绝正值 `EnvironmentDiskMiB`,零表示不请求配额。根磁盘容量仍为必需。未提供外部声明时,继续使用自有磁盘的容量边界。
+`DeploymentPolicy.Workspace` 已声明表示提供商在外部存储上运行每个 Environment,并给出挂载要求;未声明表示使用提供商自有磁盘、不使用外部存储。派生的不可变 `DeploymentSpec.Workspace` 回执记录所选能力,此类提供商始终具备该回执;未选择存储的部署返回 `ErrWorkspaceRequired`,Core 将其报告为 `workspace_storage_required`;[部署流程](../../contracts/agents-api/zh/sandbox-deployment.md#resources)不会将文件系统配置复制进代次。Microsandbox 要求 `host_directory` 和 `user_xattr`;Docker 和 E2B 拒绝外部挂载。校验使用文件系统协议的组合校验器:`EnvironmentDiskMiB` 仅是容量配额请求,仅当存储声明 `capacity_quota` 时有效,默认值为零,表示不请求配额。根磁盘容量仍为必需。
-Microsandbox 在每次 Create 和 Resume(包括观察中断的恢复)时解析绑定,仅向私有 helper 传递本地目录和不可变 ObjectID。Helper 挂载整个 `/environment`;保留内容与私有原生历史遵循[工作区文件系统范围](#lifetime-and-filesystem-scope)和 [Runtime 资源目录](configuration.md#runtime-resource-directories)。私有 HOME、凭据和计算根磁盘不是持久历史存储。原生资源通过明确的模式、对象和路径标签校验,不根据挂载形状推断模式。检查点恢复校验快照对象,通过 SDK `Volumes` 重映射 `/environment`,要求严格外部挂载策略,并拒绝任何恢复警告。恢复部分失败时保留精确目标身份用于清理,不证明就绪。未知结果继续观察原操作。原生 Bind 检查点及 guest 内 flock 连续性不证明对未知 writer 的隔离。
+Microsandbox 在每次 Create 和 Resume(包括观察中断的恢复)时解析绑定,仅向私有 helper 传递本地目录和不可变 ObjectID。Helper 挂载整个 `/environment`;保留内容与私有原生历史遵循[工作区文件系统范围](#lifetime-and-filesystem-scope)和 [Runtime 资源目录](configuration.md#runtime-resource-directories)。私有 HOME、凭据和计算根磁盘不是持久历史存储。原生资源通过对象和路径标签校验。检查点恢复校验快照对象,通过 SDK `Volumes` 重映射 `/environment`,要求严格外部挂载策略,并拒绝任何恢复警告。恢复部分失败时保留精确目标身份用于清理,不证明就绪。未知结果继续观察原操作。原生 Bind 检查点及 guest 内 flock 连续性不证明对未知 writer 的隔离。
## 内核 NFS 适配器 {#kernel-nfs-adapter}
@@ -138,7 +138,7 @@ stateDiagram-v2
NFS 存储不提供计算 fencing 或自动分布式故障转移。Guest `flock` 不是跨 VM 写入者锁。上述单写入者和显式删除要求仍然必须遵守。确认释放后,可以允许另一兼容 node 上的新 VM 挂载同一对象,但 node 离线或 Create、Kill、快照清理结果未知均不证明旧写入方已停止。原生历史和数据库行为需要对所选 Runtime、Harness、文件系统组合进行资格验证;仅保留文件不保证原生 Session 恢复、跨 node 内存恢复或任意崩溃后的持久性。
-所选部署代次使用外部工作区存储时,Core 要求所选 Harness profile 支持 `retained_native_history`。创建流程在持有部署锁时校验该代次,然后才提交 Session 或计算预留。既有 Session 的 Runtime 准入和最终执行检查以不可变工作区绑定为依据。自带工作区存储不要求此能力。
+所选部署代次使用外部工作区存储(每个 microsandbox 代次都是如此)时,Core 要求所选 Harness profile 支持 `retained_native_history`。创建流程在持有部署锁时校验该代次,然后才提交 Session 或计算预留。既有 Session 的 Runtime 准入和最终执行检查以不可变工作区绑定为依据。使用自有磁盘的提供商不要求此能力。
## 一致性测试 {#conformance}
diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts
index 67c1254dc..4b04cab05 100644
--- a/packages/agents-client/src/deployment-contract.ts
+++ b/packages/agents-client/src/deployment-contract.ts
@@ -1,3 +1,3 @@
// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.
-export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"providers":{"docker":{"mode":"nodes","disk":false,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"default_resources":null},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const;
+export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"providers":{"docker":{"mode":"nodes","disk":false,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"default_resources":null},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192}}},"minimum_disk":1024} as const;
diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts
index 2a66e6573..731667484 100644
--- a/packages/agents-client/src/sandbox-client.test.ts
+++ b/packages/agents-client/src/sandbox-client.test.ts
@@ -50,12 +50,11 @@ const docker = {
resources: { allocations: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, specification_digest: "0".repeat(64),
};
const microsandbox = {
- ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192, environment_disk_mib: 8192 } },
+ ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192 } },
suspension: { idle_seconds: 300, retention_seconds: 86400 },
};
const externalWorkspace = { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false };
-const externalDeployment = { ...microsandbox, specification: { ...microsandbox.specification,
- resources: { ...microsandbox.specification.resources, environment_disk_mib: 0 }, workspace: externalWorkspace } };
+const externalDeployment = { ...microsandbox, specification: { ...microsandbox.specification, workspace: externalWorkspace } };
/** An E2B selection saved before Core recorded its template build. */
const e2bDeployment = {
...docker, provider: "e2b", rollout: unconfigured.rollout, mode: "direct", specification: { resources: { cpus: 2, memory_mib: 2048 } },
diff --git a/services/core/internal/api/environment_input_test.go b/services/core/internal/api/environment_input_test.go
index a0803f2b3..7e8f832e1 100644
--- a/services/core/internal/api/environment_input_test.go
+++ b/services/core/internal/api/environment_input_test.go
@@ -25,6 +25,7 @@ func TestPublicEnvironmentInputFailureMappings(t *testing.T) {
{execution.ErrEnvironmentInputCancelled, http.StatusConflict, "environment_input_cancelled"},
{execution.ErrExecutionUnavailable, http.StatusServiceUnavailable, "execution_unavailable"},
{execution.ErrWhitespaceOnlyText, http.StatusBadRequest, "unsupported_or_invalid_configuration"},
+ {sessions.ErrRetainedHistoryUnsupported, http.StatusBadRequest, "unsupported_or_invalid_configuration"},
} {
t.Run(tc.code, func(t *testing.T) {
recorder := &inputRecorder{err: fmt.Errorf("submission: %w", tc.err)}
diff --git a/services/core/internal/api/errors_deployment.go b/services/core/internal/api/errors_deployment.go
index ae9cf9a48..133fe3618 100644
--- a/services/core/internal/api/errors_deployment.go
+++ b/services/core/internal/api/errors_deployment.go
@@ -72,6 +72,8 @@ func writeSandboxError(w http.ResponseWriter, err error) bool {
}
case errors.As(err, &unsupported):
writeError(w, http.StatusBadRequest, "sandbox_operation_unsupported", "The selected sandbox provider does not support this operation.")
+ case errors.Is(err, sandbox.ErrWorkspaceRequired):
+ writeError(w, http.StatusConflict, "workspace_storage_required", "This sandbox provider runs every Environment on workspace storage. Select workspace storage first.")
case errors.Is(err, sandbox.ErrInvalid):
writeError(w, http.StatusBadRequest, "invalid_sandbox_configuration", "Invalid sandbox provider configuration.", "configuration")
case errors.As(err, &stale):
diff --git a/services/core/internal/api/errors_sessions.go b/services/core/internal/api/errors_sessions.go
index 32a2328a2..6cf86d9c1 100644
--- a/services/core/internal/api/errors_sessions.go
+++ b/services/core/internal/api/errors_sessions.go
@@ -49,6 +49,8 @@ func writeSessionsError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, http.StatusConflict, "environment_input_cancelled", "The environment input was cancelled before admission.")
case errors.Is(err, execution.ErrWhitespaceOnlyText):
writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "This Session's harness does not accept a message whose text is only whitespace. Include non-whitespace text or an image, or use a harness that supports whitespace-only text.")
+ case errors.Is(err, sessions.ErrRetainedHistoryUnsupported):
+ writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk.")
case errors.Is(err, execution.ErrExecutionUnavailable):
writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not available on this service.")
case errors.As(err, &cursor):
diff --git a/services/core/internal/api/errors_test.go b/services/core/internal/api/errors_test.go
index 9ade94cb3..6295a799d 100644
--- a/services/core/internal/api/errors_test.go
+++ b/services/core/internal/api/errors_test.go
@@ -14,6 +14,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit"
@@ -137,6 +138,7 @@ func TestConflictErrorsUseConflictType(t *testing.T) {
for err, code := range map[error]string{
deployment.ErrConflict: "sandbox_deployment_conflict",
deployment.ErrNodeInUse: "runtime_node_in_use",
+ sandbox.ErrWorkspaceRequired: "workspace_storage_required",
deployment.ErrNodeAddressMismatch: "sandbox_node_address_mismatch",
sessions.ErrEnvironmentUnavailable: "environment_unavailable",
execution.ErrEnvironmentInputExpired: "environment_input_expired",
diff --git a/services/core/internal/api/testdata/core-errors.json b/services/core/internal/api/testdata/core-errors.json
index e349a2882..000b5d351 100644
--- a/services/core/internal/api/testdata/core-errors.json
+++ b/services/core/internal/api/testdata/core-errors.json
@@ -45,5 +45,6 @@
"workspace_operation_unsupported",
"workspace_storage_conflict",
"workspace_storage_not_found",
+ "workspace_storage_required",
"workspace_storage_unavailable"
]
diff --git a/services/core/internal/deployment/errors.go b/services/core/internal/deployment/errors.go
index f0c032d00..80e1f5b7f 100644
--- a/services/core/internal/deployment/errors.go
+++ b/services/core/internal/deployment/errors.go
@@ -77,8 +77,12 @@ type ConfigurationError struct {
func (e *ConfigurationError) Error() string { return e.Message }
func (e *ConfigurationError) Unwrap() error { return ErrInvalidInput }
-// configurationError wraps a provider's rejection of a selection.
-func configurationError(err error) *ConfigurationError {
+// configurationError wraps a provider's rejection of a selection. A missing
+// workspace storage selection keeps its own typed error.
+func configurationError(err error) error {
+ if errors.Is(err, sandbox.ErrWorkspaceRequired) {
+ return err
+ }
var validation *sandbox.ValidationError
errors.As(err, &validation)
return &ConfigurationError{Message: err.Error(), Validation: validation}
diff --git a/services/core/internal/deployment/rules_test.go b/services/core/internal/deployment/rules_test.go
index 97b97da8d..2b5d07cd4 100644
--- a/services/core/internal/deployment/rules_test.go
+++ b/services/core/internal/deployment/rules_test.go
@@ -123,13 +123,16 @@ func TestTypedErrors(t *testing.T) {
t.Fatal("ResetRequiredError is not a conflict")
}
validation := &sandbox.ValidationError{Param: "resources.cpus", Message: "too many"}
- err := configurationError(validation)
+ err := configurationError(validation).(*ConfigurationError)
if !errors.Is(err, ErrInvalidInput) || err.Validation != validation || err.Message != "too many" {
t.Fatalf("configurationError = %#v", err)
}
- if plain := configurationError(errors.New("rejected")); plain.Validation != nil || plain.Error() != "rejected" {
+ if plain := configurationError(errors.New("rejected")).(*ConfigurationError); plain.Validation != nil || plain.Error() != "rejected" {
t.Fatalf("configurationError of a plain error = %#v", plain)
}
+ if err := configurationError(sandbox.ErrWorkspaceRequired); !errors.Is(err, sandbox.ErrWorkspaceRequired) {
+ t.Fatal("missing storage lost its typed error", err)
+ }
if got := string(configurationJSON(nil)); got != "{}" {
t.Fatalf("configurationJSON(nil) = %s", got)
}
diff --git a/services/core/internal/deployment/service_test.go b/services/core/internal/deployment/service_test.go
index 08e61d461..51a2aca48 100644
--- a/services/core/internal/deployment/service_test.go
+++ b/services/core/internal/deployment/service_test.go
@@ -16,6 +16,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
)
const testPublicURL = "https://core.example"
@@ -59,7 +60,7 @@ func testSpecification(provider string) sandbox.DeploymentSpec {
s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}
if provider == "microsandbox" {
s.Resources.RootDiskMiB = 8192
- s.Resources.EnvironmentDiskMiB = 8192
+ s.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
}
return s
}
diff --git a/services/core/internal/deployment/workspace_configuration_test.go b/services/core/internal/deployment/workspace_configuration_test.go
index 32c427544..27891db54 100644
--- a/services/core/internal/deployment/workspace_configuration_test.go
+++ b/services/core/internal/deployment/workspace_configuration_test.go
@@ -35,7 +35,6 @@ func TestWorkspaceConfigurationRejectsChangedExternalReceiptAndReadFailure(t *te
declaration := workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
record := webDeployment(t, uuid.NewString(), "microsandbox", 1)
specification := testSpecification("microsandbox")
- specification.Resources.EnvironmentDiskMiB = 0
specification.Workspace = &declaration
record.Specification, _ = json.Marshal(specification)
var readErr error
diff --git a/services/core/internal/execution/runtime_preparation_capacity_test.go b/services/core/internal/execution/runtime_preparation_capacity_test.go
index 1e2fa76ee..6d61f3ca9 100644
--- a/services/core/internal/execution/runtime_preparation_capacity_test.go
+++ b/services/core/internal/execution/runtime_preparation_capacity_test.go
@@ -7,7 +7,6 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice"
- "strings"
"sync"
"testing"
@@ -183,45 +182,8 @@ func TestUnsettledFilesystemPreparationYieldsOnlyCompute(t *testing.T) {
if err != nil || !target.PlacementReleased || target.AllocationID != "" {
t.Fatal(target, err)
}
- // A newer generation without external storage must not displace this
- // unallocated Session's already-bound filesystem onto owned ephemeral disk.
- setup, err := r.deployments.Setup(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- updated := setup.Specification
- updated.Workspace = nil
- updated.Resources.EnvironmentDiskMiB = 8192
- view, err := r.deployment.Update(adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}), r.config.InstallationID, sandbox.Selection{Provider: setup.Provider, ExpectedGeneration: setup.Generation, DeploymentSpec: updated})
- if err != nil {
- t.Fatal(err)
- }
- token, err := r.deployments.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(2)})
- if err != nil {
- t.Fatal(err)
- }
- newer := deployment.Enrollment{NodeID: uuid.NewString(), Name: "newer owned workspace", Credential: strings.Repeat("n", 64), Provider: view.Provider, BackendFingerprint: strings.Repeat("c", 64), DeploymentGeneration: view.Generation, SpecificationDigest: view.SpecificationDigest, CoreURL: fixturePublicURL}
- if _, err := r.deployments.Enroll(t.Context(), token.Token, newer); err != nil {
- t.Fatal(err)
- }
- epoch, err := r.reader.OwnerEpoch(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- connection := uuid.NewString()
- if err := r.deployments.ConnectNode(t.Context(), newer.NodeID, connection, epoch); err != nil {
- t.Fatal(err)
- }
- if err := r.deployments.Heartbeat(t.Context(), newer.NodeID, connection, epoch, deployment.NodeHealth{}, []sandbox.GenerationStatus{{Generation: view.Generation, SpecificationDigest: view.SpecificationDigest, State: "ready", Checkpoint: &sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}}}); err != nil {
- t.Fatal(err)
- }
- demands, _, err := r.reader.PlacementDemand(t.Context(), deployment.PlacementDemandCursor{})
- if err != nil || len(demands) != 1 || !demands[0].Retained {
- t.Fatal("bound FS omitted from pressure eligibility", demands, err)
- }
- selected, err := r.deployment.EnsurePlacement(t.Context(), key, r.config.InstallationID)
- if err != nil || selected.NodeID != r.nodeID || selected.Generation != setup.Generation {
- t.Fatal("selected generation cannot attach retained filesystem", selected, err)
+ if selected, err := r.deployment.EnsurePlacement(t.Context(), key, r.config.InstallationID); err != nil || selected.NodeID != r.nodeID {
+ t.Fatal("released placement was not offered again", selected, err)
}
if _, err := r.provision(t.Context(), s.TenantID, s.Environment.ID, r.config.InstallationID); err != nil {
t.Fatal(err)
diff --git a/services/core/internal/execution/runtime_replacement_test.go b/services/core/internal/execution/runtime_replacement_test.go
index 9b3d2c7d6..e3b02677b 100644
--- a/services/core/internal/execution/runtime_replacement_test.go
+++ b/services/core/internal/execution/runtime_replacement_test.go
@@ -14,7 +14,6 @@ import (
v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest"
- "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity"
@@ -324,6 +323,7 @@ func TestRetainedEnvironmentDemandRecreatesCompute(t *testing.T) {
}
}
+// workspaceNodeSetup deploys microsandbox, which runs every Environment on external storage, or, without storage, docker.
func workspaceNodeSetup(t *testing.T, external bool) func(Owner, *deployment.Service, deployment.Reader) (string, string) {
return func(owner Owner, service *deployment.Service, reader deployment.Reader) (string, string) {
installation := uuid.NewString()
@@ -331,15 +331,19 @@ func workspaceNodeSetup(t *testing.T, external bool) func(Owner, *deployment.Ser
t.Fatal(err)
}
specification := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}
+ provider := "microsandbox"
if !external {
- specification.Workspace = nil
- specification.Resources.EnvironmentDiskMiB = 8192
+ provider, specification = "docker", sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}
}
- view, err := owner.Deployment.Initialize(t.Context(), installation, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: specification})
+ view, err := owner.Deployment.Initialize(t.Context(), installation, sandbox.Selection{Provider: provider, DeploymentSpec: specification})
if err != nil {
t.Fatal(err)
}
- token, err := service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(2)})
+ retained, checkpoint := 2, &sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}
+ if !external {
+ retained, checkpoint = 1, nil
+ }
+ token, err := service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: &retained})
if err != nil {
t.Fatal(err)
}
@@ -355,7 +359,7 @@ func workspaceNodeSetup(t *testing.T, external bool) func(Owner, *deployment.Ser
if err = service.ConnectNode(t.Context(), node.NodeID, connection, epoch); err != nil {
t.Fatal(err)
}
- if err = service.Heartbeat(t.Context(), node.NodeID, connection, epoch, deployment.NodeHealth{}, []sandbox.GenerationStatus{{Generation: view.Generation, SpecificationDigest: view.SpecificationDigest, State: "ready", Checkpoint: &sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}}}); err != nil {
+ if err = service.Heartbeat(t.Context(), node.NodeID, connection, epoch, deployment.NodeHealth{}, []sandbox.GenerationStatus{{Generation: view.Generation, SpecificationDigest: view.SpecificationDigest, State: "ready", Checkpoint: checkpoint}}); err != nil {
t.Fatal(err)
}
return installation, node.NodeID
@@ -363,48 +367,7 @@ func workspaceNodeSetup(t *testing.T, external bool) func(Owner, *deployment.Ser
}
}
-func TestProvisionUsesReservedGenerationStorageDuringUpdate(t *testing.T) {
- for _, external := range []bool{false, true} {
- t.Run(map[bool]string{false: "owned_ready_external_target", true: "external_ready_owned_target"}[external], func(t *testing.T) {
- provider := &retentionProvider{bootstraps: make(chan sandbox.Bootstrap, 1)}
- f := workspaceSettlementFixtureWithSetup(t, provider, workspaceNodeSetup(t, external))
- r, s := f.lifecycle, f.session
- setup, err := r.deployments.Setup(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- target := setup.Specification
- target.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
- target.Resources.EnvironmentDiskMiB = 0
- if external {
- target.Workspace = nil
- target.Resources.EnvironmentDiskMiB = 8192
- }
- if _, err = r.deployment.Update(adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}), r.config.InstallationID, sandbox.Selection{Provider: setup.Provider, ExpectedGeneration: setup.Generation, DeploymentSpec: target}); err != nil {
- t.Fatal(err)
- }
- // Neither today's target nor the lane's cached storage mode selects storage.
- r.config.Workspace = target.Workspace
- owner, err := r.provision(t.Context(), s.TenantID, s.Environment.ID, r.config.InstallationID)
- if err != nil {
- t.Fatal(err)
- }
- if owner.DeploymentGeneration != setup.Generation {
- t.Fatal("lost reserved generation", owner)
- }
- bootstrap := <-provider.bootstraps
- if (bootstrap.Workspace != nil) != external {
- t.Fatal("storage mode followed target instead of reservation", external, bootstrap.Workspace)
- }
- environment, err := r.sessions.GetEnvironment(t.Context(), s.TenantID, s.Environment.ID)
- if err != nil || environment.ExternalWorkspace != external {
- t.Fatal("immutable binding projection", environment, err)
- }
- })
- }
-}
-
-func TestProvisionRejectsRetainedBindingOnOwnedGeneration(t *testing.T) {
+func TestProvisionRejectsRetainedBindingOnProviderWithoutStorage(t *testing.T) {
provider := &retentionProvider{bootstraps: make(chan sandbox.Bootstrap, 1)}
f := workspaceSettlementFixtureWithSetup(t, provider, workspaceNodeSetup(t, false))
r, s := f.lifecycle, f.session
@@ -421,67 +384,3 @@ func TestProvisionRejectsRetainedBindingOnOwnedGeneration(t *testing.T) {
t.Fatal("incompatible compute reserved", err)
}
}
-
-// A deterministic provider stops after capturing Resume, before subsequent
-// wake operations. The allocation, generation and filesystem binding are real.
-type resumeStorageProvider struct {
- retentionProvider
- request *sandbox.ResumeRequest
- stop error
-}
-
-func (p *resumeStorageProvider) Resume(_ context.Context, request sandbox.ResumeRequest) (sandbox.ComputeState, error) {
- p.request = &request
- return sandbox.ComputeState{}, p.stop
-}
-
-func TestRestoreUsesAllocationGenerationStorageInsteadOfCachedLane(t *testing.T) {
- for _, external := range []bool{false, true} {
- t.Run(map[bool]string{false: "owned_allocation_external_lane", true: "external_allocation_owned_lane"}[external], func(t *testing.T) {
- stop := errors.New("resume request captured")
- provider := &resumeStorageProvider{stop: stop}
- f := workspaceSettlementFixtureWithSetup(t, provider, workspaceNodeSetup(t, external))
- r, s := f.lifecycle, f.session
- owner, err := r.provision(t.Context(), s.TenantID, s.Environment.ID, r.config.InstallationID)
- if err != nil {
- t.Fatal(err)
- }
- setup, err := r.deployments.Setup(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- target := setup.Specification
- target.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
- target.Resources.EnvironmentDiskMiB = 0
- if external {
- target.Workspace = nil
- target.Resources.EnvironmentDiskMiB = 8192
- }
- audit := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()})
- if _, err = r.deployment.Update(audit, r.config.InstallationID, sandbox.Selection{Provider: setup.Provider, ExpectedGeneration: setup.Generation, DeploymentSpec: target}); err != nil {
- t.Fatal(err)
- }
- r.config.Workspace = target.Workspace
- r.config.Resources = target.Resources
- state := runtimeCompute{Target: &sandbox.Compute{ID: "replacement-compute", Generation: 2}, Snapshot: &sandbox.SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}, ID: "snapshot"}, RestoreID: uuid.NewString()}
- if err = r.restoreCompute(t.Context(), provider, owner, state, false); !errors.Is(err, stop) {
- t.Fatal(err)
- }
- if provider.request == nil || (provider.request.Workspace != nil) != external {
- t.Fatal("Resume binding followed cached lane", external, provider.request)
- }
- if external {
- stored, err := f.storage.Get(t.Context(), s.TenantID, s.Environment.ID)
- if err != nil || provider.request.Workspace.Attachment.Reference != stored.Reference {
- t.Fatal("Resume lost retained object identity", err)
- }
- }
- provider.request = nil
- stale := owner
- stale.ID = uuid.NewString()
- if err = r.restoreCompute(t.Context(), provider, stale, state, false); !errors.Is(err, sandbox.ErrOwnership) || provider.request != nil {
- t.Fatal("stale owner reached native Resume", err)
- }
- })
- }
-}
diff --git a/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go b/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go
index c13ad8399..2bead77c1 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/checkpoint_transfer_test.go
@@ -47,7 +47,7 @@ func suspendedCheckpointOwner(t *testing.T, f fixture, changes *deployment.Execu
func TestCheckpointTransferPreservesAllocationAndFencesOldOwner(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
source := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
readyCheckpointNode(t, f, source, view, checkpointClass)
target := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
@@ -94,7 +94,7 @@ func TestCheckpointTransferRequiresCompatibleGenerationAndCapacity(t *testing.T)
t.Run(obstruction, func(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
source := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 2})
readyCheckpointNode(t, f, source, view, checkpointClass)
target := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
@@ -163,7 +163,7 @@ func TestCheckpointTransferRequiresCompatibleGenerationAndCapacity(t *testing.T)
func TestCheckpointTransfersSerializeDestinationCapacity(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
source := f.enroll(t, view, deployment.Capacity{MaxActive: 2, MaxRetained: 2})
readyCheckpointNode(t, f, source, view, checkpointClass)
target := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
@@ -196,7 +196,7 @@ func TestCheckpointTransfersSerializeDestinationCapacity(t *testing.T) {
func TestCheckpointCleanupRelocatesArtifactWithoutExecutionCompatibility(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
source := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
readyCheckpointNode(t, f, source, view, checkpointClass)
target := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 2})
@@ -241,7 +241,7 @@ func TestCheckpointPressureSuspendsOnlyUsableDestination(t *testing.T) {
t.Run(scenario, func(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
source := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
readyCheckpointNode(t, f, source, view, checkpointClass)
suspendedCheckpointOwner(t, f, changes, installation, source, view)
@@ -279,7 +279,7 @@ func TestCheckpointPressureSuspendsOnlyUsableDestination(t *testing.T) {
func TestCheckpointRestoreUsesRetainedSlotAndImmutableGeneration(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
source := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
readyCheckpointNode(t, f, source, view, checkpointClass)
owner := suspendedCheckpointOwner(t, f, changes, installation, source, view)
diff --git a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go
index aa7175e1b..1d6def668 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go
@@ -16,6 +16,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -94,7 +95,7 @@ func testSpecification(provider string) sandbox.DeploymentSpec {
s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}
if provider == "microsandbox" {
s.Resources.RootDiskMiB = 8192
- s.Resources.EnvironmentDiskMiB = 8192
+ s.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
}
return s
}
diff --git a/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go b/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go
index 1e2f410b6..f98086397 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/placement_demand_test.go
@@ -13,7 +13,7 @@ import (
func TestPlacementDemandAdmitsOnlyAvailableCompute(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 3, MaxRetained: 10})
f.connect(t, node.NodeID)
keys := make([]deployment.AllocationKey, 10)
@@ -64,7 +64,7 @@ func TestPlacementDemandAdmitsOnlyAvailableCompute(t *testing.T) {
func TestPlacementDemandPagesByOriginalDemandTime(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
at := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond)
for i := 0; i < 40; i++ {
key := pendingHostedEnvironment(t, f)
@@ -97,7 +97,7 @@ func TestPlacementDemandPagesByOriginalDemandTime(t *testing.T) {
func TestFirstPlacementChecksActualGenerationHistorySupport(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
f.connect(t, node.NodeID)
key := pendingHostedEnvironment(t, f)
@@ -139,7 +139,7 @@ func pendingHostedEnvironment(t *testing.T, f fixture) deployment.AllocationKey
func TestReleasedFileWakeUsesFirstDemandAndLatestOwner(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 2})
f.connect(t, node.NodeID)
key := hostedEnvironment(t, f.pool)
@@ -185,7 +185,7 @@ func TestReleasedFileWakeUsesFirstDemandAndLatestOwner(t *testing.T) {
func TestPlacementDemandWrapsDespiteContinuousNewArrivals(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
add := func(n int) {
for range n {
pendingHostedEnvironment(t, f)
diff --git a/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go b/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go
index 8b3a3d984..dc240edea 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/replacement_test.go
@@ -7,9 +7,7 @@ import (
"time"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment"
- "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
- "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
"github.com/google/uuid"
)
@@ -48,7 +46,7 @@ func releaseRetainedFixture(t *testing.T, f fixture, changes *deployment.Executi
func TestReplacementKeepsSessionIdentityAndFencesPriorOwner(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
firstNode := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
f.connect(t, firstNode.NodeID)
secondNode := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
@@ -144,7 +142,7 @@ func TestReplacementKeepsSessionIdentityAndFencesPriorOwner(t *testing.T) {
func TestReplacementRefusesUnsettledOwnershipAndUnknownHistory(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
f.connect(t, node.NodeID)
key := hostedEnvironment(t, f.pool)
@@ -185,7 +183,7 @@ func TestReplacementRefusesUnsettledOwnershipAndUnknownHistory(t *testing.T) {
func TestTenRetainedWorkspacesShareThreeComputeSlots(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 3, MaxRetained: 3})
f.connect(t, node.NodeID)
keys := make([]deployment.AllocationKey, 10)
@@ -255,7 +253,7 @@ func TestTenRetainedWorkspacesShareThreeComputeSlots(t *testing.T) {
func TestResetArchivesRetainedSessionBeforeClearingDeployment(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
f.connect(t, node.NodeID)
retained := make([]deployment.Allocation, 37)
@@ -345,7 +343,7 @@ func TestResetArchivesRetainedSessionBeforeClearingDeployment(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- next, err := changes.Initialize(admin(t), installation, sandbox.Selection{Provider: "microsandbox", ExpectedGeneration: committed, DeploymentSpec: retainedSpecification()})
+ next, err := changes.Initialize(admin(t), installation, sandbox.Selection{Provider: "microsandbox", ExpectedGeneration: committed, DeploymentSpec: testSpecification("microsandbox")})
if err != nil {
t.Fatal(err)
}
@@ -359,53 +357,3 @@ func TestResetArchivesRetainedSessionBeforeClearingDeployment(t *testing.T) {
t.Fatal("archive lost files or kept Session executable", status, fsState, err)
}
}
-
-func retainedSpecification() sandbox.DeploymentSpec {
- spec := testSpecification("microsandbox")
- spec.Resources.RootDiskMiB = 8192
- spec.Resources.EnvironmentDiskMiB = 0
- spec.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
- return spec
-}
-
-func TestReplacementWaitsForCompatibleReadyGenerationWithoutReservingOwnedNode(t *testing.T) {
- f := newFixture(t)
- changes, _ := f.execution(t)
- installation, owned := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
- oldNode := f.enroll(t, owned, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
- f.connect(t, oldNode.NodeID)
- external, err := changes.Update(admin(t), installation, sandbox.Selection{Provider: "microsandbox", ExpectedGeneration: owned.Generation, DeploymentSpec: retainedSpecification()})
- if err != nil {
- t.Fatal(err)
- }
- newNode := f.enroll(t, external, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
- f.connect(t, newNode.NodeID)
- key := hostedEnvironment(t, f.pool)
- if _, err = f.pool.Exec(t.Context(), `INSERT INTO runtime_placements(environment_id,node_id,deployment_generation) VALUES($1,$2,$3)`, key.EnvironmentID, newNode.NodeID, external.Generation); err != nil {
- t.Fatal(err)
- }
- owner, err := changes.ReserveAllocation(t.Context(), key, installation, credentialHash())
- if err != nil {
- t.Fatal(err)
- }
- releaseRetainedFixture(t, f, changes, owner)
- if _, err = f.pool.Exec(t.Context(), `UPDATE runtime_nodes SET connection_id=NULL WHERE id=$1`, newNode.NodeID); err != nil {
- t.Fatal(err)
- }
- if _, err = changes.EnsurePlacement(t.Context(), key, installation); !errors.Is(err, placement.ErrNodeUnavailable) {
- t.Fatal("owned generation admitted retained FS", err)
- }
- var released bool
- if err = f.pool.QueryRow(t.Context(), `SELECT released_at IS NOT NULL FROM runtime_placements WHERE environment_id=$1`, key.EnvironmentID).Scan(&released); err != nil || !released {
- t.Fatal("incompatible placement consumed capacity", released, err)
- }
- f.connect(t, newNode.NodeID)
- reserved, err := changes.EnsurePlacement(t.Context(), key, installation)
- if err != nil || reserved.NodeID != newNode.NodeID {
- t.Fatal("compatible capacity did not recover demand", reserved, err)
- }
- fresh, err := changes.ReserveAllocation(t.Context(), key, installation, credentialHash())
- if err != nil || fresh.ID == owner.ID || fresh.NodeID != newNode.NodeID {
- t.Fatal(fresh, err)
- }
-}
diff --git a/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go b/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go
index 266f36f01..33ab1b24c 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go
@@ -38,7 +38,7 @@ func qualifyPressureRetention(t *testing.T, f fixture, owner deployment.Allocati
func TestRetentionPressureReleasesOldestRetainableCheckpoint(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 3, MaxRetained: 3})
f.connect(t, node.NodeID)
var owners []deployment.Allocation
@@ -98,7 +98,7 @@ func TestRetentionPressureKeepsIneligibleCheckpoints(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
f.connect(t, node.NodeID)
owner := qualifyPressureRetention(t, f, runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation))
@@ -134,7 +134,7 @@ func TestPressureSuspendsReleasableOwnerWhenRetainedSlotsAreFull(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1})
f.connect(t, node.NodeID)
owner := qualifyPressureRetention(t, f, runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation))
diff --git a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go
index ab811e76c..898bf2559 100644
--- a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go
+++ b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure_test.go
@@ -197,7 +197,7 @@ func TestPressureSuspensionServesRestoreOnItsOriginalNode(t *testing.T) {
func TestPressureDemandScansPastIncompatiblePage(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
capacity := deployment.Capacity{MaxActive: 1, MaxRetained: 2}
node := f.enroll(t, view, capacity)
f.connect(t, node.NodeID)
@@ -224,7 +224,7 @@ func TestPressureDemandRejectsUnqualifiedRetainedReceipt(t *testing.T) {
t.Run(badReceipt, func(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: retainedSpecification()})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 2})
f.connect(t, node.NodeID)
count := 1
@@ -261,15 +261,11 @@ func TestPressureDemandRejectsUnqualifiedRetainedReceipt(t *testing.T) {
}
func TestPressureReclamationIgnoresUnavailableNodeReceipts(t *testing.T) {
- for _, obstruction := range []string{"offline expired", "offline quiescing", "offline suspending", "different address", "incompatible generation"} {
+ for _, obstruction := range []string{"offline expired", "offline quiescing", "offline suspending", "different address"} {
t.Run(obstruction, func(t *testing.T) {
f := newFixture(t)
changes, _ := f.execution(t)
- spec := retainedSpecification()
- if obstruction == "incompatible generation" {
- spec = testSpecification("microsandbox")
- }
- installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: spec})
+ installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")})
badNode := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 2})
f.connect(t, badNode.NodeID)
stuck := runningPressureOwner(t, f, changes, installation, badNode.NodeID, view.Generation)
@@ -294,22 +290,10 @@ func TestPressureReclamationIgnoresUnavailableNodeReceipts(t *testing.T) {
if _, err := f.pool.Exec(t.Context(), `UPDATE runtime_nodes SET core_url='https://other.example' WHERE id=$1`, badNode.NodeID); err != nil {
t.Fatal(err)
}
- case "incompatible generation":
- var err error
- view, err = changes.Update(admin(t), installation, sandbox.Selection{Provider: "microsandbox", ExpectedGeneration: view.Generation, DeploymentSpec: retainedSpecification()})
- if err != nil {
- t.Fatal(err)
- }
}
node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 2})
f.connect(t, node.NodeID)
- var waiting deployment.AllocationKey
- if obstruction == "incompatible generation" {
- old := releaseRetainedFixture(t, f, changes, runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation))
- waiting = old.Key()
- } else {
- waiting = pendingPressureDemand(t, f)
- }
+ waiting := pendingPressureDemand(t, f)
// A real pending reservation supplies demand and retains its original deadline.
if _, err := f.pool.Exec(t.Context(), `INSERT INTO environment_input_reservations(id,session_id,idempotency_key,batch,created_at,deadline) SELECT $1,session_id,'pressure','[{}]',clock_timestamp(),clock_timestamp()+interval '5 minutes' FROM environments WHERE id=$2`, uuid.NewString(), waiting.EnvironmentID); err != nil {
t.Fatal(err)
diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go
index 904885c43..5ef002c60 100644
--- a/services/core/internal/sandbox/deployment.go
+++ b/services/core/internal/sandbox/deployment.go
@@ -39,11 +39,13 @@ type Resources struct {
EnvironmentDiskMiB uint32 `json:"environment_disk_mib,omitempty"`
}
+// ValidatePolicy validates the resources against a Provider's declaration. On
+// external storage, environment_disk_mib is only the capacity quota request: zero
+// requests none and the storage declaration decides whether a quota is possible.
func (r Resources) ValidatePolicy(provider string, rules DeploymentPolicy) error {
- return r.ValidateWorkspacePolicy(provider, rules, nil)
-}
-
-func (r Resources) validatePolicy(provider string, rules DeploymentPolicy) error {
+ if rules.Workspace != nil && r.EnvironmentDiskMiB == 0 && rules.Disk {
+ r.EnvironmentDiskMiB = minimumDiskMiB
+ }
values := reflect.ValueOf(r)
for i, rule := range resourceContract {
min, max := rule.Min, rule.Max
@@ -54,7 +56,10 @@ func (r Resources) validatePolicy(provider string, rules DeploymentPolicy) error
message = provider + " does not support independent disk capacity limits"
if rules.Disk {
min, max = minimumDiskMiB, ^uint32(0)
- message = provider + " requires root_disk_mib and environment_disk_mib of at least 1024 MiB"
+ message = provider + " requires " + rule.Name + " of at least 1024 MiB"
+ if rules.Workspace != nil && rule.Name == "environment_disk_mib" {
+ message = provider + " requires environment_disk_mib of 0 or at least 1024 MiB"
+ }
maximum = nil
} else {
maximum = validationBound(max)
@@ -75,8 +80,20 @@ type DeploymentSpec struct {
}
// ValidatePolicy applies a registered adapter's rules without knowing its kind.
+// A Provider that declares workspace requirements runs every Environment on
+// external storage and cannot be deployed without a selection.
func (s DeploymentSpec) ValidatePolicy(provider string, policy DeploymentPolicy) error {
- return s.ValidateWorkspacePolicy(provider, policy, s.Workspace)
+ switch {
+ case policy.Workspace == nil && s.Workspace != nil:
+ return workspacefs.ErrUnsupported
+ case policy.Workspace != nil && s.Workspace == nil:
+ return ErrWorkspaceRequired
+ case policy.Workspace != nil:
+ if err := workspacefs.ValidateCombination(*policy.Workspace, *s.Workspace, s.Resources.EnvironmentDiskMiB); err != nil {
+ return err
+ }
+ }
+ return s.Resources.ValidatePolicy(provider, policy)
}
func (s DeploymentSpec) Digest(provider string) string {
@@ -99,24 +116,3 @@ func BackendFingerprint(kind, namespace string) string {
digest := sha256.Sum256([]byte(kind + "\x00" + namespace))
return hex.EncodeToString(digest[:])
}
-
-// ValidateWorkspacePolicy validates explicit external storage without weakening root disk limits.
-func (r Resources) ValidateWorkspacePolicy(provider string, rules DeploymentPolicy, declaration *workspacefs.Declaration) error {
- if declaration == nil {
- return r.validatePolicy(provider, rules)
- }
- if rules.Workspace == nil {
- return workspacefs.ErrUnsupported
- }
- if err := workspacefs.ValidateCombination(*rules.Workspace, *declaration, r.EnvironmentDiskMiB); err != nil {
- return err
- }
- checked := r
- if checked.EnvironmentDiskMiB == 0 && rules.Disk {
- checked.EnvironmentDiskMiB = minimumDiskMiB
- }
- return checked.validatePolicy(provider, rules)
-}
-func (s DeploymentSpec) ValidateWorkspacePolicy(provider string, policy DeploymentPolicy, declaration *workspacefs.Declaration) error {
- return s.Resources.ValidateWorkspacePolicy(provider, policy, declaration)
-}
diff --git a/services/core/internal/sandbox/microsandbox/checkpoint_linux.go b/services/core/internal/sandbox/microsandbox/checkpoint_linux.go
index e28018709..ff7412118 100644
--- a/services/core/internal/sandbox/microsandbox/checkpoint_linux.go
+++ b/services/core/internal/sandbox/microsandbox/checkpoint_linux.go
@@ -89,9 +89,9 @@ func CheckpointClass(c Config) (sandbox.CheckpointCompatibility, error) {
raw, _ := json.Marshal(struct {
SDK, Runtime, Firmware, Image, Kernel string
CPUs uint8
- Memory, Root, Environment uint32
+ Memory, Root uint32
CPU []string
- }{SDKVersion, c.RuntimeSHA256, c.FirmwareSHA256, c.Image, strings.TrimSpace(string(kernel)), c.CPUs, c.MemoryMiB, c.RootDiskMiB, c.EnvironmentDiskMiB, stable})
+ }{SDKVersion, c.RuntimeSHA256, c.FirmwareSHA256, c.Image, strings.TrimSpace(string(kernel)), c.CPUs, c.MemoryMiB, c.RootDiskMiB, stable})
h := sha256.Sum256(raw)
return sandbox.CheckpointCompatibility{ArtifactDomain: domain, ExecutionClass: hex.EncodeToString(h[:])}, nil
}
diff --git a/services/core/internal/sandbox/microsandbox/contract_test.go b/services/core/internal/sandbox/microsandbox/contract_test.go
index 7f3bbef62..56e2fdee8 100644
--- a/services/core/internal/sandbox/microsandbox/contract_test.go
+++ b/services/core/internal/sandbox/microsandbox/contract_test.go
@@ -12,7 +12,7 @@ import (
func TestProviderContract(t *testing.T) {
contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture {
c, r := testConfig(), testRef()
- b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic"}
+ b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", Workspace: workspaceBinding()}
var calls []string
p, err := NewWithCaller(c, callerFunc(func(ctx context.Context, q Request) (Response, error) {
calls = append(calls, q.Operation)
@@ -39,6 +39,7 @@ func TestProviderContract(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ resolveTo(p)
nativeOperation := s.Operation
if nativeOperation == "renew" || nativeOperation == "inspect" {
nativeOperation = "initial_info"
@@ -62,7 +63,8 @@ func TestProviderContractObservation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic"}
+ resolveTo(p)
+ b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", Workspace: workspaceBinding()}
got, err := p.Create(deadline(t), b)
contracttest.AssertObservation(t, got, err, r, "native-owned", "running")
got, err = p.GetInfo(deadline(t), r)
diff --git a/services/core/internal/sandbox/microsandbox/creation_settlement_test.go b/services/core/internal/sandbox/microsandbox/creation_settlement_test.go
index 517b3ecd2..dbc937791 100644
--- a/services/core/internal/sandbox/microsandbox/creation_settlement_test.go
+++ b/services/core/internal/sandbox/microsandbox/creation_settlement_test.go
@@ -11,7 +11,7 @@ import (
func TestCreateConfigurationRejectionSettlement(t *testing.T) {
config, ref := testConfig(), testRef()
bootstrap := sandbox.Bootstrap{Reference: ref, SessionID: ref.TenantID, DeviceID: ref.EnvironmentID,
- CoreURL: "https://core.example/api/v1", Credential: "fixture"}
+ CoreURL: "https://core.example/api/v1", Credential: "fixture", Workspace: workspaceBinding()}
for _, test := range []struct {
name string
change func(*Response)
@@ -50,6 +50,7 @@ func TestCreateConfigurationRejectionSettlement(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ resolveTo(provider)
info, err := provider.Create(deadline(t), bootstrap)
if err == nil || info.CreateSettled != test.settled || info.BootstrapComplete || calls != 1 {
t.Fatal("rejection changed readiness or settlement", info, err, calls)
diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go
index 3329198f7..bd471f9d3 100644
--- a/services/core/internal/sandbox/microsandbox/deployment.go
+++ b/services/core/internal/sandbox/microsandbox/deployment.go
@@ -7,7 +7,7 @@ import (
func Policy() sandbox.DeploymentPolicy {
return sandbox.DeploymentPolicy{Workspace: &workspacefs.Requirements{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}, Disk: true,
- DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}}
+ DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192}}
}
func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) }
diff --git a/services/core/internal/sandbox/microsandbox/identity.go b/services/core/internal/sandbox/microsandbox/identity.go
index e81ca3e5d..ddcc8d185 100644
--- a/services/core/internal/sandbox/microsandbox/identity.go
+++ b/services/core/internal/sandbox/microsandbox/identity.go
@@ -26,7 +26,7 @@ func (c Config) Validate() error {
if !filepath.IsAbs(c.CheckpointRoot) || filepath.Clean(c.CheckpointRoot) != c.CheckpointRoot {
return sandbox.ErrInvalid
}
- if !validID(c.InstallationID) || !filepath.IsAbs(c.HelperPath) || !filepath.IsAbs(c.RuntimeHome) || !filepath.IsAbs(c.RuntimePath) || !filepath.IsAbs(c.FirmwarePath) || !validHash(c.RuntimeSHA256) || !validHash(c.FirmwareSHA256) || c.MemoryMiB == 0 || c.CPUs == 0 || c.RootDiskMiB == 0 || (!c.ExternalWorkspace && c.EnvironmentDiskMiB == 0) {
+ if !validID(c.InstallationID) || !filepath.IsAbs(c.HelperPath) || !filepath.IsAbs(c.RuntimeHome) || !filepath.IsAbs(c.RuntimePath) || !filepath.IsAbs(c.FirmwarePath) || !validHash(c.RuntimeSHA256) || !validHash(c.FirmwareSHA256) || c.MemoryMiB == 0 || c.CPUs == 0 || c.RootDiskMiB == 0 {
return sandbox.ErrInvalid
}
imageName, digest, pinned := strings.Cut(c.Image, "@sha256:")
@@ -109,10 +109,7 @@ func ValidateRequest(q Request) error {
}
switch q.Operation {
case "create":
- if q.Config.ExternalWorkspace != (q.Workspace != nil) {
- return sandbox.ErrInvalid
- }
- if (q.Workspace == nil && q.Config.EnvironmentDiskMiB == 0) || (q.Bootstrap != nil && q.Bootstrap.Workspace != nil) || q.Bootstrap == nil || q.Bootstrap.Reference != q.Reference || ValidateBootstrap(*q.Bootstrap) != nil {
+ if q.Workspace == nil || (q.Bootstrap != nil && q.Bootstrap.Workspace != nil) || q.Bootstrap == nil || q.Bootstrap.Reference != q.Reference || ValidateBootstrap(*q.Bootstrap) != nil {
return sandbox.ErrInvalid
}
case "initial_info":
@@ -142,11 +139,8 @@ func ValidateRequest(q Request) error {
return sandbox.ErrInvalid
}
case "resume":
- if q.Config.ExternalWorkspace != (q.Workspace != nil) {
- return sandbox.ErrInvalid
- }
v := q.Resume
- if (q.Workspace == nil && q.Config.EnvironmentDiskMiB == 0) || (v != nil && v.Workspace != nil) || v == nil || v.Reference != q.Reference || !validID(v.OperationID) || ValidateSnapshot(q.Config, q.Reference, v.Snapshot) != nil || ValidateCompute(q.Config, q.Reference, v.Target) != nil || v.Target.RestoredFrom == nil || *v.Target.RestoredFrom != v.Snapshot {
+ if q.Workspace == nil || (v != nil && v.Workspace != nil) || v == nil || v.Reference != q.Reference || !validID(v.OperationID) || ValidateSnapshot(q.Config, q.Reference, v.Snapshot) != nil || ValidateCompute(q.Config, q.Reference, v.Target) != nil || v.Target.RestoredFrom == nil || *v.Target.RestoredFrom != v.Snapshot {
return sandbox.ErrInvalid
}
case "delete_snapshot":
diff --git a/services/core/internal/sandbox/microsandbox/node.go b/services/core/internal/sandbox/microsandbox/node.go
index 4dd9df035..50b864f7a 100644
--- a/services/core/internal/sandbox/microsandbox/node.go
+++ b/services/core/internal/sandbox/microsandbox/node.go
@@ -60,10 +60,9 @@ func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, caller *Pr
}
resources := spec.Resources
config := Config{
- ExternalWorkspace: spec.Workspace != nil,
- InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, CheckpointRoot: entry.CheckpointRoot, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath,
+ InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, CheckpointRoot: entry.CheckpointRoot, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath,
RuntimeSHA256: entry.RuntimeSHA256, FirmwareSHA256: entry.FirmwareSHA256, Image: entry.Image,
- MemoryMiB: resources.MemoryMiB, CPUs: uint8(resources.CPUs), RootDiskMiB: resources.RootDiskMiB, EnvironmentDiskMiB: resources.EnvironmentDiskMiB, Network: network,
+ MemoryMiB: resources.MemoryMiB, CPUs: uint8(resources.CPUs), RootDiskMiB: resources.RootDiskMiB, Network: network,
}
provider, err := NewWithCaller(config, caller)
if err != nil {
diff --git a/services/core/internal/sandbox/microsandbox/node_test.go b/services/core/internal/sandbox/microsandbox/node_test.go
index 2a8b91cc8..656bb567e 100644
--- a/services/core/internal/sandbox/microsandbox/node_test.go
+++ b/services/core/internal/sandbox/microsandbox/node_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
"github.com/google/uuid"
)
@@ -35,7 +36,7 @@ func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- config := sandbox.NodeConfig{Provider: "microsandbox", Generation: 9, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}}, Native: raw}
+ config := sandbox.NodeConfig{Provider: "microsandbox", Generation: 9, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}, Native: raw}
for _, path := range []string{native.RuntimePath, native.FirmwarePath, native.HelperPath} {
if err := os.WriteFile(path, script, 0700); err != nil {
t.Fatal(err)
diff --git a/services/core/internal/sandbox/microsandbox/provider.go b/services/core/internal/sandbox/microsandbox/provider.go
index 9d4d368fd..d39b79237 100644
--- a/services/core/internal/sandbox/microsandbox/provider.go
+++ b/services/core/internal/sandbox/microsandbox/provider.go
@@ -144,9 +144,6 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf
if err := ValidateBootstrap(b); err != nil {
return absent, err
}
- if p.config.ExternalWorkspace != (b.Workspace != nil) {
- return absent, sandbox.ErrInvalid
- }
workspace, err := p.resolveWorkspace(ctx, b.Reference, b.Workspace)
if err != nil {
return absent, err
@@ -279,7 +276,7 @@ func (p *Provider) NewCompute(ctx context.Context, r sandbox.Reference, generati
func (p *Provider) resolveWorkspace(ctx context.Context, reference sandbox.Reference, binding *workspacefs.Binding) (*WorkspaceDirectory, error) {
if binding == nil {
- return nil, nil
+ return nil, sandbox.ErrInvalid
}
if err := sandbox.ValidateWorkspaceBinding(reference, binding); err != nil {
return nil, err
diff --git a/services/core/internal/sandbox/microsandbox/provider_test.go b/services/core/internal/sandbox/microsandbox/provider_test.go
index db67511af..753ad9593 100644
--- a/services/core/internal/sandbox/microsandbox/provider_test.go
+++ b/services/core/internal/sandbox/microsandbox/provider_test.go
@@ -17,7 +17,7 @@ func testConfig() Config {
return Config{CheckpointRoot: "/private-checkpoints",
InstallationID: "11111111-1111-4111-8111-111111111111", HelperPath: "/helper", RuntimeHome: "/private/msb", RuntimePath: "/private/bin/msb", FirmwarePath: "/private/lib/libkrunfw.so",
RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), Image: "registry/runtime@sha256:" + strings.Repeat("c", 64),
- MemoryMiB: 2048, CPUs: 2, RootDiskMiB: 4096, EnvironmentDiskMiB: 2048, Network: NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny", Rules: []NetworkRule{{Action: "allow", Direction: "egress", Destination: "host"}}},
+ MemoryMiB: 2048, CPUs: 2, RootDiskMiB: 4096, Network: NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny", Rules: []NetworkRule{{Action: "allow", Direction: "egress", Destination: "host"}}},
}
}
func testRef() sandbox.Reference {
@@ -60,7 +60,8 @@ func TestRestoreMustRetainExactProvenance(t *testing.T) {
got.RestoredFrom = &v
return Response{Version: ProtocolVersion, State: &State{Compute: got, Status: "running", BootstrapComplete: true}}, nil
}))
- _, e := p.Resume(deadline(t), ResumeRequest{Reference: r, OperationID: "66666666-6666-4666-8666-666666666666", Snapshot: s, Target: target})
+ resolveTo(p)
+ _, e := p.Resume(deadline(t), ResumeRequest{Reference: r, OperationID: "66666666-6666-4666-8666-666666666666", Snapshot: s, Target: target, Workspace: workspaceBinding()})
if !errors.Is(e, sandbox.ErrOwnership) {
t.Fatalf("different snapshot accepted: %v", e)
}
diff --git a/services/core/internal/sandbox/microsandbox/types.go b/services/core/internal/sandbox/microsandbox/types.go
index a9b53badb..06274f3b6 100644
--- a/services/core/internal/sandbox/microsandbox/types.go
+++ b/services/core/internal/sandbox/microsandbox/types.go
@@ -9,7 +9,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
)
-const ProtocolVersion = 6
+const ProtocolVersion = 7
const SDKVersion = "v0.7.8"
const MaxOutputBytes = 1024 * 1024
const MaxRequestBytes = 72 * 1024 * 1024
@@ -18,22 +18,19 @@ const MaxResponseBytes = 16 * 1024 * 1024
// Config is trusted deployment configuration. Paths and hashes refer to one
// immutable, qualified installation. Network is explicitly used on create and restore.
type Config struct {
- // ExternalWorkspace is derived from the immutable deployment specification.
- ExternalWorkspace bool
- InstallationID string
- HelperPath string
- RuntimeHome string
- CheckpointRoot string
- RuntimePath string
- FirmwarePath string
- RuntimeSHA256 string
- FirmwareSHA256 string
- Image string
- MemoryMiB uint32
- CPUs uint8
- RootDiskMiB uint32
- EnvironmentDiskMiB uint32
- Network NetworkPolicy
+ InstallationID string
+ HelperPath string
+ RuntimeHome string
+ CheckpointRoot string
+ RuntimePath string
+ FirmwarePath string
+ RuntimeSHA256 string
+ FirmwareSHA256 string
+ Image string
+ MemoryMiB uint32
+ CPUs uint8
+ RootDiskMiB uint32
+ Network NetworkPolicy
}
type NetworkPolicy struct {
diff --git a/services/core/internal/sandbox/microsandbox/workspace_test.go b/services/core/internal/sandbox/microsandbox/workspace_test.go
index b5a805081..c469a6254 100644
--- a/services/core/internal/sandbox/microsandbox/workspace_test.go
+++ b/services/core/internal/sandbox/microsandbox/workspace_test.go
@@ -20,10 +20,15 @@ func workspaceBinding() *workspacefs.Binding {
c := workspacefs.Configuration{ID: "77777777-7777-4777-8777-777777777777", Adapter: "test", Parameters: json.RawMessage(`{}`)}
return &workspacefs.Binding{Configuration: c, Attachment: workspacefs.Attachment{Reference: workspacefs.Reference{TenantID: r.TenantID, EnvironmentID: r.EnvironmentID, ObjectID: "88888888-8888-4888-8888-888888888888"}, ConfigurationID: c.ID, Kind: workspacefs.AttachmentHostDirectory, Native: json.RawMessage(`{}`)}}
}
+
+// resolveTo gives a Provider the resolver every Environment needs.
+func resolveTo(p *Provider) {
+ p.workspace = workspaceResolverFunc(func(context.Context, workspacefs.Binding) (workspacefs.Directory, error) {
+ return workspacefs.Directory{Path: "/resolved/environment"}, nil
+ })
+}
func TestWorkspaceResolveBeforeRestoreAndRetainPartialTarget(t *testing.T) {
c, r, s := testConfig(), testRef(), testSnapshot()
- c.EnvironmentDiskMiB = 0
- c.ExternalWorkspace = true
resolves, calls := 0, 0
p, err := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) {
calls++
@@ -60,7 +65,6 @@ func TestWorkspaceResolveBeforeRestoreAndRetainPartialTarget(t *testing.T) {
func TestWorkspaceDoesNotFallbackWithoutResolver(t *testing.T) {
calls := 0
config := testConfig()
- config.ExternalWorkspace = true
p, _ := NewWithCaller(config, callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil }))
_, err := p.Create(deadline(t), sandbox.Bootstrap{Reference: testRef(), SessionID: testRef().TenantID, DeviceID: testRef().EnvironmentID, CoreURL: "https://core.example/api/v1", Credential: "fixture", Workspace: workspaceBinding()})
if !errors.Is(err, workspacefs.ErrUnsupported) || calls != 0 {
@@ -72,7 +76,6 @@ func TestPreNativeWorkspaceFailureSettlesAbsentCreation(t *testing.T) {
for _, failure := range []string{"resolve", "mode", "bootstrap", "unbounded"} {
t.Run(failure, func(t *testing.T) {
config, ref := testConfig(), testRef()
- config.ExternalWorkspace = true
calls := 0
provider, err := NewWithCaller(config, callerFunc(func(context.Context, Request) (Response, error) { calls++; return Response{}, nil }))
if err != nil {
diff --git a/services/core/internal/sandbox/node/workspace_test.go b/services/core/internal/sandbox/node/workspace_test.go
index 0623cfce6..519f57de6 100644
--- a/services/core/internal/sandbox/node/workspace_test.go
+++ b/services/core/internal/sandbox/node/workspace_test.go
@@ -68,7 +68,7 @@ func (c *workspaceRefusalCaller) Call(context.Context, microsandbox.Request) (mi
func TestActualWorkspaceRefusalKeepsSettledAbsenceOnWire(t *testing.T) {
ref := reference()
caller := new(workspaceRefusalCaller)
- config := microsandbox.Config{ExternalWorkspace: true, InstallationID: "11111111-1111-4111-8111-111111111111", HelperPath: "/helper", RuntimeHome: "/runtime", CheckpointRoot: "/checkpoints", RuntimePath: "/runtime/msb", FirmwarePath: "/runtime/firmware", RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), Image: "image@sha256:" + strings.Repeat("c", 64), CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 4096, Network: microsandbox.NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny"}}
+ config := microsandbox.Config{InstallationID: "11111111-1111-4111-8111-111111111111", HelperPath: "/helper", RuntimeHome: "/runtime", CheckpointRoot: "/checkpoints", RuntimePath: "/runtime/msb", FirmwarePath: "/runtime/firmware", RuntimeSHA256: strings.Repeat("a", 64), FirmwareSHA256: strings.Repeat("b", 64), Image: "image@sha256:" + strings.Repeat("c", 64), CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 4096, Network: microsandbox.NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny"}}
provider, err := microsandbox.NewWithCaller(config, caller)
if err != nil {
t.Fatal(err)
diff --git a/services/core/internal/sandbox/providers/generation_test.go b/services/core/internal/sandbox/providers/generation_test.go
index 9abb76f54..5dd51c02b 100644
--- a/services/core/internal/sandbox/providers/generation_test.go
+++ b/services/core/internal/sandbox/providers/generation_test.go
@@ -14,6 +14,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
sandboxmicro "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
"github.com/google/uuid"
)
@@ -21,7 +22,8 @@ func generationConfig(t *testing.T) (sandbox.NodeConfig, sandboxmicro.Native) {
t.Helper()
dir := t.TempDir()
spec := validRegistrationSpec()
- spec.Resources.RootDiskMiB, spec.Resources.EnvironmentDiskMiB = 8192, 8192
+ spec.Resources.RootDiskMiB = 8192
+ spec.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
native := sandboxmicro.Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, CheckpointRoot: t.TempDir(), RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"),
Image: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64),
Network: sandboxmicro.Network{DefaultEgress: "allow", DefaultIngress: "deny"}}
@@ -56,7 +58,7 @@ func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) {
}
}
config, _ := generationConfig(t)
- built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state})
+ built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state, Workspace: unreachableWorkspaceResolver{}})
closeProvider()
if mode == "private" {
info, statErr := os.Lstat(directory)
@@ -78,7 +80,7 @@ func TestMicrosandboxGenerationBindsLeaseIdentity(t *testing.T) {
registry := Builtin()
config, native := generationConfig(t)
state := t.TempDir()
- built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state})
+ built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: state, Workspace: unreachableWorkspaceResolver{}})
if err != nil {
t.Fatal(err)
}
@@ -157,7 +159,7 @@ func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) {
config, native := generationConfig(t)
native.Image = image
config.Native, _ = json.Marshal(native)
- built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: t.TempDir()})
+ built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: t.TempDir(), Workspace: unreachableWorkspaceResolver{}})
closeProvider()
if err == nil || built != nil {
t.Fatalf("accepted image %q", image)
@@ -171,10 +173,10 @@ func TestLocalConstructionRequiresExplicitContext(t *testing.T) {
state := t.TempDir()
config, _ := generationConfig(t)
for _, options := range []sandbox.LocalOptions{
- {},
- {Standalone: true, GenerationStateDirectory: state},
- {GenerationStateDirectory: "relative"},
- {GenerationStateDirectory: state + "/../node"},
+ {Workspace: unreachableWorkspaceResolver{}},
+ {Standalone: true, GenerationStateDirectory: state, Workspace: unreachableWorkspaceResolver{}},
+ {GenerationStateDirectory: "relative", Workspace: unreachableWorkspaceResolver{}},
+ {GenerationStateDirectory: state + "/../node", Workspace: unreachableWorkspaceResolver{}},
} {
built, closeProvider, err := registry.Build(config, options)
closeProvider()
@@ -182,7 +184,7 @@ func TestLocalConstructionRequiresExplicitContext(t *testing.T) {
t.Fatalf("accepted construction context %+v: %v", options, err)
}
}
- built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{Standalone: true})
+ built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{Standalone: true, Workspace: unreachableWorkspaceResolver{}})
closeProvider()
if err != nil || built == nil {
t.Fatalf("standalone construction: %v", err)
diff --git a/services/core/internal/sandbox/providers/validation_test.go b/services/core/internal/sandbox/providers/validation_test.go
index 821a91ce6..ff3adfc91 100644
--- a/services/core/internal/sandbox/providers/validation_test.go
+++ b/services/core/internal/sandbox/providers/validation_test.go
@@ -17,8 +17,8 @@ func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) {
}{
{"docker", sandbox.Resources{}, "resources.cpus", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(1), validationBound(255)},
{"docker", sandbox.Resources{CPUs: 1, MemoryMiB: 511}, "resources.memory_mib", "cpus must be 1..255 and memory_mib must be 512..1048576", validationBound(512), validationBound(1048576)},
- {"microsandbox", sandbox.Resources{CPUs: 1, MemoryMiB: 512}, "resources.root_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil},
- {"microsandbox", sandbox.Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1024}, "resources.environment_disk_mib", "microsandbox requires root_disk_mib and environment_disk_mib of at least 1024 MiB", validationBound(1024), nil},
+ {"microsandbox", sandbox.Resources{CPUs: 1, MemoryMiB: 512}, "resources.root_disk_mib", "microsandbox requires root_disk_mib of at least 1024 MiB", validationBound(1024), nil},
+ {"microsandbox", sandbox.Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1024, EnvironmentDiskMiB: 1}, "resources.environment_disk_mib", "microsandbox requires environment_disk_mib of 0 or at least 1024 MiB", validationBound(1024), nil},
{"docker", sandbox.Resources{CPUs: 1, MemoryMiB: 512, RootDiskMiB: 1}, "resources.root_disk_mib", "docker does not support independent disk capacity limits", validationBound(0), validationBound(0)},
{"e2b", sandbox.Resources{CPUs: 1, MemoryMiB: 512, EnvironmentDiskMiB: 1}, "resources.environment_disk_mib", "e2b does not support independent disk capacity limits", validationBound(0), validationBound(0)},
} {
diff --git a/services/core/internal/sandbox/providers/workspace_test.go b/services/core/internal/sandbox/providers/workspace_test.go
index b1f2c505e..fe0fbb231 100644
--- a/services/core/internal/sandbox/providers/workspace_test.go
+++ b/services/core/internal/sandbox/providers/workspace_test.go
@@ -12,7 +12,6 @@ func TestWorkspaceReceiptValidatesNormalizationAndGenerationBuild(t *testing.T)
registry := Builtin()
spec := validRegistrationSpec()
spec.Resources.RootDiskMiB = 4096
- spec.Resources.EnvironmentDiskMiB = 0
spec.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
if err := registry.ValidateSpecification("microsandbox", spec); err != nil {
t.Fatal(err)
@@ -35,8 +34,8 @@ func TestWorkspaceReceiptValidatesNormalizationAndGenerationBuild(t *testing.T)
t.Fatal("external generation built without resolver", err)
}
config.Specification.Workspace = nil
- if _, _, err = registry.Build(config, sandbox.LocalOptions{Standalone: true}); err == nil || errors.Is(err, reached) {
- t.Fatal("owned zero disk bypassed validation", err)
+ if _, _, err = registry.Build(config, sandbox.LocalOptions{Standalone: true, Workspace: unreachableWorkspaceResolver{}}); !errors.Is(err, sandbox.ErrWorkspaceRequired) {
+ t.Fatal("generation without storage bypassed validation", err)
}
}
diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go
index acc47ccc1..d4fc5b8e3 100644
--- a/services/core/internal/sandbox/sandbox_provider.go
+++ b/services/core/internal/sandbox/sandbox_provider.go
@@ -35,7 +35,10 @@ import (
)
var (
- ErrInvalid = errors.New("invalid sandbox configuration")
+ ErrInvalid = errors.New("invalid sandbox configuration")
+ // ErrWorkspaceRequired reports a Provider that runs every Environment on
+ // external storage while none is selected.
+ ErrWorkspaceRequired = errors.New("sandbox provider requires workspace storage")
ErrOwnership = errors.New("sandbox ownership mismatch")
ErrExists = errors.New("sandbox allocation already exists")
ErrNotFound = errors.New("sandbox allocation not found")
@@ -301,9 +304,10 @@ type ConfigurationRequirements struct {
CredentialVerification providercontract.Support
}
-// DeploymentPolicy is the deployment declaration. Disk declares independent
-// disk limits. DefaultResources is the size setup proposes, or nil when the
-// Provider's configuration selects it.
+// DeploymentPolicy is the deployment declaration. Workspace, when present, means
+// the Provider runs every Environment on external storage; absent means its own
+// disk. Disk declares independent disk limits. DefaultResources is the size setup
+// proposes, or nil when the Provider's configuration selects it.
type DeploymentPolicy struct {
Workspace *workspacefs.Requirements `json:"workspace,omitempty"`
Disk bool `json:"disk"`
diff --git a/services/core/internal/sandbox/testdata/deployment-contract.json b/services/core/internal/sandbox/testdata/deployment-contract.json
index 2e76faa7e..f9b24c5d1 100644
--- a/services/core/internal/sandbox/testdata/deployment-contract.json
+++ b/services/core/internal/sandbox/testdata/deployment-contract.json
@@ -36,11 +36,16 @@
"memory_mib": 1048576,
"root_disk_mib": 1024,
"environment_disk_mib": 4294967295
+ },
+ "workspace": {
+ "attachment": "host_directory",
+ "user_xattr": true,
+ "capacity_quota": true
}
},
"valid": true,
- "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295}}",
- "digest": "bab416199b7aa94421b948fa3dfcb55a4547fa56209395b2a4dbaeacbefe6e72"
+ "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":true}}",
+ "digest": "a0f5348e68e36ff01ffcef3e6c71c5eddfd429f34b440a4b3bb273b8f9eeac94"
},
{
"name": "e2b",
@@ -132,6 +137,11 @@
"memory_mib": 1048576,
"root_disk_mib": 1023,
"environment_disk_mib": 4294967295
+ },
+ "workspace": {
+ "attachment": "host_directory",
+ "user_xattr": true,
+ "capacity_quota": true
}
},
"valid": false
@@ -222,7 +232,7 @@
"valid": false
},
{
- "name": "microsandbox-owned-zero-environment-rejected",
+ "name": "microsandbox-requires-workspace",
"provider": "microsandbox",
"specification": {
"resources": {
diff --git a/services/core/internal/sandbox/workspace_test.go b/services/core/internal/sandbox/workspace_test.go
index d8a0aee70..d22cd3d06 100644
--- a/services/core/internal/sandbox/workspace_test.go
+++ b/services/core/internal/sandbox/workspace_test.go
@@ -1,27 +1,32 @@
package sandbox
import (
+ "errors"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
"testing"
)
-func TestWorkspaceQuotaDoesNotWeakenRootCapacity(t *testing.T) {
+func TestExternalStorageProviderRequiresSelectionAndKeepsRootCapacity(t *testing.T) {
policy := DeploymentPolicy{Disk: true, Workspace: &workspacefs.Requirements{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}
- declaration := workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
- resources := Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 4096}
- if err := resources.ValidateWorkspacePolicy("test", policy, &declaration); err != nil {
+ declaration := &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
+ spec := DeploymentSpec{Resources: Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 4096}, Workspace: declaration}
+ if err := spec.ValidatePolicy("test", policy); err != nil {
t.Fatal(err)
}
- if err := resources.ValidatePolicy("test", policy); err == nil {
- t.Fatal("owned disk accepted zero")
+ if err := (DeploymentSpec{Resources: spec.Resources}).ValidatePolicy("test", policy); !errors.Is(err, ErrWorkspaceRequired) {
+ t.Fatal("missing storage accepted", err)
}
- resources.EnvironmentDiskMiB = 4096
- if err := resources.ValidateWorkspacePolicy("test", policy, &declaration); err == nil {
+ if err := spec.ValidatePolicy("test", DeploymentPolicy{}); !errors.Is(err, workspacefs.ErrUnsupported) {
+ t.Fatal("storage accepted by a Provider with its own disk", err)
+ }
+ quota := spec
+ quota.Resources.EnvironmentDiskMiB = 4096
+ if err := quota.ValidatePolicy("test", policy); err == nil {
t.Fatal("unenforced external quota accepted")
}
- resources.EnvironmentDiskMiB = 0
- resources.RootDiskMiB = 0
- if err := resources.ValidateWorkspacePolicy("test", policy, &declaration); err == nil {
+ noRoot := spec
+ noRoot.Resources.RootDiskMiB = 0
+ if err := noRoot.ValidatePolicy("test", policy); err == nil {
t.Fatal("root quota omitted")
}
}
diff --git a/services/core/internal/sessions/environment.go b/services/core/internal/sessions/environment.go
index dd0afb7ae..0172dcda1 100644
--- a/services/core/internal/sessions/environment.go
+++ b/services/core/internal/sessions/environment.go
@@ -141,7 +141,7 @@ type EnvironmentFileWrite struct {
// ValidateRetainedHistory checks the selected external filesystem and Harness combination.
func ValidateRetainedHistory(required, supported bool) error {
if required && !supported {
- return fmt.Errorf("%w: external workspace storage requires retained_native_history", ErrInvalidInput)
+ return ErrRetainedHistoryUnsupported
}
return nil
}
diff --git a/services/core/internal/sessions/errors.go b/services/core/internal/sessions/errors.go
index 116914bb3..3b14d76d5 100644
--- a/services/core/internal/sessions/errors.go
+++ b/services/core/internal/sessions/errors.go
@@ -23,6 +23,11 @@ var ErrInputPending = fmt.Errorf("%w: session input is still pending", ErrTurnCo
// internal callers; an expired Environment keeps that plain error.
var ErrHostedEnvironmentFailed = fmt.Errorf("%w: the hosted environment failed to provision", ErrEnvironmentUnavailable)
+// ErrRetainedHistoryUnsupported rejects a Harness without retained native
+// history on external workspace storage. It remains ErrInvalidInput for
+// internal callers.
+var ErrRetainedHistoryUnsupported = fmt.Errorf("%w: external workspace storage requires retained_native_history", ErrInvalidInput)
+
// ErrNotIdle rejects deletion of a Session that still has work or input
// pending. Callers cancel first and delete after the Session settles.
var ErrNotIdle = errors.New("session must be durably idle or failed without required actions before deletion")
diff --git a/services/core/tests/integration/root_fixture_test.go b/services/core/tests/integration/root_fixture_test.go
index a29b67530..4d4367215 100644
--- a/services/core/tests/integration/root_fixture_test.go
+++ b/services/core/tests/integration/root_fixture_test.go
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
+ "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement"
@@ -60,12 +61,15 @@ func (s *Store) CreateSession(ctx context.Context, tenant string, input sessions
}
// createSession runs Session creation as cmd/server does, on s's database,
-// credential key and placement rules.
+// credential key and placement rules, with the built-in Harness declarations.
func createSession(ctx context.Context, s *Store, tenant string, input sessions.CreateSession) (sessions.Creation, error) {
service, err := newSessionService(s)
if err != nil {
return sessions.Creation{}, err
}
+ if declaration, ok := builtin.Registry().Lookup(input.Engine); ok {
+ input.SupportsRetainedNativeHistory = declaration.Capabilities.RetainedNativeHistory.IsSupported()
+ }
return service.CreateSession(ctx, tenant, input)
}
diff --git a/services/core/tests/integration/runtime_compute_lifecycle_test.go b/services/core/tests/integration/runtime_compute_lifecycle_test.go
index ce1df45c9..9b164bd5b 100644
--- a/services/core/tests/integration/runtime_compute_lifecycle_test.go
+++ b/services/core/tests/integration/runtime_compute_lifecycle_test.go
@@ -192,7 +192,7 @@ func (p *fakeCheckpointProvider) connect(ctx context.Context, b sandbox.Bootstra
if err != nil {
return err
}
- heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}}})
+ heartbeat, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{RetainedNativeHistory: proto.CapabilitySupported})}}})
if err := conn.WriteJSON(heartbeat); err != nil {
conn.Close()
return err
diff --git a/services/core/tests/integration/sandbox_specification_lifecycle_test.go b/services/core/tests/integration/sandbox_specification_lifecycle_test.go
index 0ac2be436..a157d6004 100644
--- a/services/core/tests/integration/sandbox_specification_lifecycle_test.go
+++ b/services/core/tests/integration/sandbox_specification_lifecycle_test.go
@@ -22,6 +22,9 @@ func webSpecificationFixture(t *testing.T, provider string) (*Store, *Store, dep
w := executionWriter(t, s)
id := uuid.NewString()
changes := deploymentExecution(t, w)
+ if provider == "microsandbox" {
+ selectWorkspaceStorage(t, fixtureWorkspaces(w, w.lease))
+ }
if err := changes.Claim(t.Context(), id); err != nil {
t.Fatal(err)
}
diff --git a/services/core/tests/integration/sandbox_specification_test.go b/services/core/tests/integration/sandbox_specification_test.go
index fab731b79..3ba03726c 100644
--- a/services/core/tests/integration/sandbox_specification_test.go
+++ b/services/core/tests/integration/sandbox_specification_test.go
@@ -3,13 +3,14 @@ package integration
import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
)
func SandboxDeploymentTestSpec(provider string) sandbox.DeploymentSpec {
s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}
if provider == "microsandbox" {
s.Resources.RootDiskMiB = 8192
- s.Resources.EnvironmentDiskMiB = 8192
+ s.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
}
return s
}
diff --git a/services/core/tests/integration/worker_fixture_test.go b/services/core/tests/integration/worker_fixture_test.go
index 590936407..e3c7f3679 100644
--- a/services/core/tests/integration/worker_fixture_test.go
+++ b/services/core/tests/integration/worker_fixture_test.go
@@ -17,6 +17,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
)
@@ -145,6 +146,7 @@ func fixtureOwner(s *Store, lease *pgunit.Lease) (execution.Owner, error) {
return execution.Owner{}, err
}
return execution.Owner{
+ Workspaces: fixtureWorkspaces(s, lease),
Lease: lease,
Deployment: changes,
Sessions: sessionExecution,
@@ -165,6 +167,9 @@ func webDeployment(t *testing.T, s *Store, provider string) string {
if err != nil {
t.Fatal(err)
}
+ if provider == "microsandbox" {
+ selectWorkspaceStorage(t, owner.Workspaces)
+ }
installation := uuid.NewString()
input := sandbox.Selection{Provider: provider, DeploymentSpec: SandboxDeploymentTestSpec(provider)}
if provider == "e2b" {
@@ -188,8 +193,13 @@ func webRuntimes(t testing.TB, s *Store, installation string, p sandbox.SandboxP
if err != nil || setup.Provider == "" {
return nil, err
}
+ adapter, err := providers.Builtin().Lookup(setup.Provider)
+ if err != nil {
+ return nil, err
+ }
return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Generation: setup.Generation,
- CoreURL: "http://core.invalid/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p, Suspension: suspension}, nil
+ CoreURL: "http://core.invalid/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: p, Suspension: suspension,
+ Resources: setup.Specification.Resources, WorkspaceRequirements: adapter.Policy.Workspace, Workspace: setup.Specification.Workspace}, nil
}, unusedPreparation(t))
}
diff --git a/services/core/tests/integration/workspace_fixture_test.go b/services/core/tests/integration/workspace_fixture_test.go
new file mode 100644
index 000000000..37c7bc87a
--- /dev/null
+++ b/services/core/tests/integration/workspace_fixture_test.go
@@ -0,0 +1,65 @@
+package integration
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/workspacepg"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspacefs"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/workspaces"
+)
+
+// fixtureWorkspaceInstallation seals workspace configurations in the tests; the
+// fixture adapter keeps no credential.
+const fixtureWorkspaceInstallation = "fixture-installation"
+
+// fixtureWorkspaces builds the workspace execution operations on lease as
+// cmd/server does, with the fixture adapter in place of the registered ones so
+// no test needs NFS.
+func fixtureWorkspaces(s *Store, lease *pgunit.Lease) *workspaces.ExecutionOperations {
+ return workspaces.NewExecution(workspacepg.New(s.pooled, s.credentialCipher, fixtureWorkspaceInstallation),
+ workspacepg.NewExecution(lease, s.credentialCipher, fixtureWorkspaceInstallation), fixtureControls{}, lease)
+}
+
+// selectWorkspaceStorage selects the fixture adapter on operations as an operator
+// does before Web setup.
+func selectWorkspaceStorage(t testing.TB, operations *workspaces.ExecutionOperations) {
+ t.Helper()
+ selection := workspaces.SelectionInput{Selection: workspaces.Selection{Configuration: workspacefs.Configuration{ID: uuid.NewString(), Adapter: "fixture", Parameters: json.RawMessage(`{}`)}}}
+ ctx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()})
+ if _, err := operations.Configure(ctx, selection, func(workspacefs.Declaration) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// fixtureControls constructs the fixture adapter for every configuration.
+type fixtureControls struct{}
+
+func (fixtureControls) Normalize(c workspacefs.Configuration, _ json.RawMessage) (workspacefs.ConfigurationRecord, error) {
+ return workspacefs.ConfigurationRecord{Configuration: c}, nil
+}
+
+func (fixtureControls) Control(c workspacefs.ConfigurationRecord) (workspacefs.Control, error) {
+ return fixtureControl{configuration: c.ID}, nil
+}
+
+// fixtureControl declares what microsandbox requires and issues an attachment
+// for every Reference; Core never resolves paths.
+type fixtureControl struct{ configuration string }
+
+func (fixtureControl) Declaration() workspacefs.Declaration {
+ return workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}
+}
+func (fixtureControl) Check(context.Context) error { return nil }
+func (c fixtureControl) Create(_ context.Context, r workspacefs.Reference, _ uint32) (workspacefs.Attachment, error) {
+ return c.Observe(context.Background(), r)
+}
+func (c fixtureControl) Observe(_ context.Context, r workspacefs.Reference) (workspacefs.Attachment, error) {
+ return workspacefs.Attachment{Reference: r, ConfigurationID: c.configuration, Kind: workspacefs.AttachmentHostDirectory, Native: json.RawMessage(`{}`)}, nil
+}
+func (fixtureControl) Delete(context.Context, workspacefs.Reference) error { return nil }
diff --git a/services/core/tools/microsandbox-provider/README.md b/services/core/tools/microsandbox-provider/README.md
index 9c1cefe54..bf7d44563 100644
--- a/services/core/tools/microsandbox-provider/README.md
+++ b/services/core/tools/microsandbox-provider/README.md
@@ -1,6 +1,6 @@
# microsandbox Sandbox Provider helper
-microsandbox runs each hosted Session in its own microVM on a Linux amd64 node with KVM, and it is the Sandbox Provider that supports idle suspension. Core forwards provider operations to the node over the [node protocol](../../../../contracts/agents-api/node-generation-protocol.md); the node's adapter ([`sandbox/microsandbox`](../../internal/sandbox/microsandbox)) runs this helper once per operation. The private helper wire version is 6; mismatches are rejected. The helper links the microsandbox Go SDK v0.7.8 with its FFI library, so Core and the node program stay CGO-free Go binaries. It implements the checkpoint operations of the provider-neutral `sandbox.SandboxProvider` with full snapshots. It has no daemon, lifecycle database, scheduler or network control plane.
+microsandbox runs each hosted Session in its own microVM on a Linux amd64 node with KVM, and it is the Sandbox Provider that supports idle suspension. Core forwards provider operations to the node over the [node protocol](../../../../contracts/agents-api/node-generation-protocol.md); the node's adapter ([`sandbox/microsandbox`](../../internal/sandbox/microsandbox)) runs this helper once per operation. The private helper wire version is 7; mismatches are rejected. The helper links the microsandbox Go SDK v0.7.8 with its FFI library, so Core and the node program stay CGO-free Go binaries. It implements the checkpoint operations of the provider-neutral `sandbox.SandboxProvider` with full snapshots. It has no daemon, lifecycle database, scheduler or network control plane.
[Sandbox Provider protocol](../../../../docs/sandbox-provider.md) owns the provider contract. [Sandbox deployment](../../../../contracts/agents-api/sandbox-deployment.md) owns the resources and suspension policy; the [nodes guide](../../../../docs/getting-started/nodes.md) owns node installation, host requirements, the node's directories and its network policy.
@@ -18,9 +18,9 @@ The checkpoint root is a separate adapter-owned directory, mode 0700, with a pri
## Create and bootstrap
-Create names the VM from a hash of the installation and allocation reference plus the compute generation; a name never serves another incarnation. It creates the VM with the saved CPUs and memory as both initial and maximum, a managed root disk of `root_disk_mib`, an owned ext4 disk of `environment_disk_mib` or the explicitly resolved external directory mounted at `/environment`, user 1000:1000, working directory `/` and the node's network policy ([`bootstrap.go`](bootstrap.go)). The resource checks run before bootstrap. Creation and restore retain the SDK's strict hostname policy enforcement; the adapter does not disable it.
+Create names the VM from a hash of the installation and allocation reference plus the compute generation; a name never serves another incarnation. It creates the VM with the saved CPUs and memory as both initial and maximum, a managed root disk of `root_disk_mib`, the explicitly resolved workspace directory mounted at `/environment`, user 1000:1000, working directory `/` and the node's network policy ([`bootstrap.go`](bootstrap.go)). The resource checks run before bootstrap. Creation and restore retain the SDK's strict hostname policy enforcement; the adapter does not disable it.
-Workspace, staging and outputs share the `/environment` filesystem, which keeps the Runtime's cross-device and link checks intact; the layered root filesystem can report different device IDs for a directory and its upper-layer files, so it holds no workspace data. In owned mode, full snapshots and sandbox removal capture, restore and reclaim this disk. In external mode, the filesystem provider owns the directory; sandbox removal never deletes it. The binding and resource rules belong to [Workspace filesystem protocol](../../../../docs/workspace-provider.md#attachment). The [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) owns the Runtime directory layout.
+Workspace, staging and outputs share the `/environment` filesystem, which keeps the Runtime's cross-device and link checks intact; the layered root filesystem can report different device IDs for a directory and its upper-layer files, so it holds no workspace data. The filesystem provider owns the directory; sandbox removal never deletes it. The binding and resource rules belong to [Workspace filesystem protocol](../../../../docs/workspace-provider.md#attachment). The [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) owns the Runtime directory layout.
VM creation does not run the image's entry point. The bootstrap runs as root through confidential standard input, with a two-minute limit. It creates the Runtime directories and the private control directory `/run/oac` (mode 0700, owned by UID 1000), writes the [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) file to `/home/runtime/runtime-bootstrap.json`, bind-mounts `/environment/workspace` at `/workspace` and starts `oac-daemon connect --bootstrap-file` in the background as UID/GID 1000. `OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE=/run/oac/daemon-suspend.json` enables the daemon's park and wake control. The `io.oac.bootstrap` label then changes from `pending` to `complete` through the SDK's next-start modification policy, because v0.7.8 cannot update the labels of a running VM. That label confirms only these writes and the launch, not authentication or native readiness.
@@ -33,7 +33,7 @@ Core persists operation IDs, source and target generations, exact identities and
- **Suspend** pauses the exact VM, captures and verifies the full checkpoint closure, then exports an SDK archive without the Runtime image into the private checkpoint root; the execution class pins that image, which every compatible node holds. The adapter hashes the archive once as it publishes it and synchronizes the archive and its digest, size and ownership receipt before force-stopping the exact paused source, removing its writable disks and deleting its local SDK snapshot. Only after that cleanup is synchronized does `SourceStopped` authorize transfer. A matching artifact is completed instead of captured again; a running source is never killed to finish an old capture.
- **KillCompute** fences future admission for the exact target and checks its incarnation before stopping and removing it. Under that dispatch fence the SDK serializes against native launch, waits for the runtime lifecycle lock on Kill, and rechecks the terminal incarnation under that lock on Remove. A final typed absence permits a durable cleanup receipt, including for an admitted restore whose launcher died. This proves the writer is gone, never that it did not execute.
- **Restore** requires the source-settled archive receipt, matching artifact domain and execution class, and the complete archive hash before importing through the SDK, then the imported snapshot's full verification. Before native restore, the snapshot's captured image digest must equal the pinned Runtime image digest and that image must be in the local image store; otherwise restore fails as invalid without starting the target. This metadata check does not prove that every cached image object is present. The pinned SDK's restore has no pull-policy option and uses `if-missing`; missing cache objects can trigger a registry request pinned to the captured manifest digest. A failed resolution leaves the restore unresolved for exact observation and cleanup; it never substitutes another image or cold-boots the Session. Restore then creates the precommitted target name. An existing target is adopted only when its immutable ID, if known, and its persisted `snapshot_parent` agree. Upstream restore defaults to public networking, so restore passes the same explicit host policy as creation, and no undeclared host resource or mount is inherited.
-- Native restore leaves the managed root size unset because the target inherits the verified full snapshot. The helper accepts that only with a matching snapshot resource proof and the exact source and target identities, and it checks the target's CPU, memory and Environment disk before keeping the inherited proof. A missing root size never counts as unlimited capacity, and retained state is never resized.
+- Native restore leaves the managed root size unset because the target inherits the verified full snapshot. The helper accepts that only with a matching snapshot resource proof and the exact source and target identities, and it checks the target's CPU and memory before keeping the inherited proof. A missing root size never counts as unlimited capacity, and retained state is never resized.
- Fresh restore and retry share one completion: verify the original artifact and resource proof, inspect the running target's resources and ancestry, persist its missing derived resource-proof label, then strictly reread the same native ID ([`restore_completion.go`](restore_completion.go)). A conflicting proof is an error. Native restore does not copy the source's ownership labels; ancestry supplies that evidence. There is no ordinary Start, replacement, disk-only restore or cold boot.
- **ResumeCompute** thaws the same resident source after an aborted suspension. The pinned SDK handle method is name-based; the allocation lock and ID checks before and after the call fence every managed replacement. Manual lifecycle changes in the managed namespace are unsupported.
- **DeleteSnapshot** accepts only the derived operation selector and matching artifact identity. It compares the SDK descriptor digest, which covers the checkpoint root, instead of re-hashing payloads, so damaged content never blocks owned cleanup. It fences delayed publication and restoration with a durable deletion receipt before deleting the local SDK snapshot and portable archive. An unsettled admitted restore blocks deletion. The small ownership tombstone remains; Core owns retention and cleanup order.
diff --git a/services/core/tools/microsandbox-provider/bootstrap.go b/services/core/tools/microsandbox-provider/bootstrap.go
index 7992e2bc7..d6d829ce9 100644
--- a/services/core/tools/microsandbox-provider/bootstrap.go
+++ b/services/core/tools/microsandbox-provider/bootstrap.go
@@ -50,10 +50,6 @@ func (b backend) create(ctx context.Context) (wire.Response, error) {
for key, value := range workspaceLabels(b.q.Workspace) {
labels[key] = value
}
- mount := sdk.Mount.Owned(sdk.OwnedVolumeOptions{Kind: sdk.VolumeKindDisk, SizeMiB: b.q.Config.EnvironmentDiskMiB})
- if b.q.Workspace != nil {
- mount = sdk.Mount.Bind(b.q.Workspace.Path, sdk.MountOptions{})
- }
live, e := sdk.CreateSandbox(ctx, c.Name,
// Every compatible node holds the pinned image; nothing is ever pulled.
sdk.WithImage(b.q.Config.Image), sdk.WithPullPolicy(sdk.PullPolicyNever), sdk.WithMemory(b.q.Config.MemoryMiB), sdk.WithCPUs(b.q.Config.CPUs),
@@ -62,7 +58,7 @@ func (b backend) create(ctx context.Context) (wire.Response, error) {
// The selected Environment mount keeps workspace and staging on one filesystem.
// Bootstrap creates their directories before starting the daemon.
sdk.WithWorkdir("/"), sdk.WithMounts(map[string]sdk.MountConfig{
- "/environment": mount,
+ "/environment": sdk.Mount.Bind(b.q.Workspace.Path, sdk.MountOptions{}),
}),
sdk.WithLabels(labels), sdk.WithDetached(), sdk.WithQuietLogs(), sdk.WithNetwork(b.network()),
sdk.WithEnv(map[string]string{
@@ -86,7 +82,7 @@ func (b backend) create(ctx context.Context) (wire.Response, error) {
var actual struct {
Labels map[string]string `json:"labels"`
}
- if json.Unmarshal([]byte(h.ConfigJSON()), &actual) != nil || qualifyWorkspace(actual.Labels, b.q.Workspace) != nil || (b.q.Workspace != nil && actual.Labels[workspacePathLabel] != b.q.Workspace.Path) {
+ if json.Unmarshal([]byte(h.ConfigJSON()), &actual) != nil || qualifyWorkspace(actual.Labels, b.q.Workspace) != nil || actual.Labels[workspacePathLabel] != b.q.Workspace.Path {
qualified.CreateSettled = true
return qualified, sandbox.ErrInvalid
}
diff --git a/services/core/tools/microsandbox-provider/creation_settlement_test.go b/services/core/tools/microsandbox-provider/creation_settlement_test.go
index 6242bbab6..484438497 100644
--- a/services/core/tools/microsandbox-provider/creation_settlement_test.go
+++ b/services/core/tools/microsandbox-provider/creation_settlement_test.go
@@ -5,6 +5,7 @@ package main
import (
"encoding/json"
"errors"
+ "maps"
"testing"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
@@ -18,7 +19,7 @@ func TestCreatedConfigurationRejectionRequiresExactOwnedCompute(t *testing.T) {
ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"}
created := wire.Compute{Name: wire.Name(config, ref, 0), ID: "local:created"}
labels := wire.Labels(config, ref)
- labels[workspaceModeLabel] = "owned"
+ maps.Copy(labels, workspaceLabels(fixtureWorkspace))
labels[bootstrapLabel] = "pending"
actual["labels"] = labels
actualID, status := created.ID, "running"
diff --git a/services/core/tools/microsandbox-provider/deployment.go b/services/core/tools/microsandbox-provider/deployment.go
index 9e2683ef1..322aacaf3 100644
--- a/services/core/tools/microsandbox-provider/deployment.go
+++ b/services/core/tools/microsandbox-provider/deployment.go
@@ -16,7 +16,6 @@ import (
sdk "github.com/superradcompany/microsandbox/sdk/go"
)
-const workspaceModeLabel = "io.oac.workspace-mode"
const workspaceObjectLabel = "io.oac.workspace-object"
const workspacePathLabel = "io.oac.workspace-path"
@@ -24,17 +23,15 @@ const resourceProofLabel = "io.oac.resource-proof"
func resourceProof(config wire.Config) string {
raw, _ := json.Marshal(struct {
- ExternalWorkspace bool
- Image string
- Resources sandbox.Resources
- }{config.ExternalWorkspace, config.Image, sandbox.Resources{CPUs: uint32(config.CPUs), MemoryMiB: config.MemoryMiB,
- RootDiskMiB: config.RootDiskMiB, EnvironmentDiskMiB: config.EnvironmentDiskMiB}})
+ Image string
+ Resources sandbox.Resources
+ }{config.Image, sandbox.Resources{CPUs: uint32(config.CPUs), MemoryMiB: config.MemoryMiB, RootDiskMiB: config.RootDiskMiB}})
digest := sha256.Sum256(raw)
return hex.EncodeToString(digest[:])
}
// The SDK decodes native CPU/memory/rootfs configuration. Its v0.7.8 projection
-// omits mounts, so the explicitly owned or external inventory is projected here.
+// omits mounts, so the external directory inventory is projected here.
// Restored roots have no configured size: a verified full snapshot supplies that
// proof, retained as a label only after inspecting the restored target.
func qualifyConfiguration(config wire.Config, compute wire.Compute, raw string, inheritedRoot bool) error {
@@ -53,38 +50,18 @@ func qualifyConfiguration(config wire.Config, compute wire.Compute, raw string,
}
var inventory struct {
Mounts []struct {
- Type string `json:"type"`
- Host string `json:"host"`
- Guest string `json:"guest"`
- Storage struct {
- Kind string `json:"kind"`
- CapacityMiB uint32 `json:"capacity_mib"`
- } `json:"storage"`
+ Type string `json:"type"`
+ Host string `json:"host"`
+ Guest string `json:"guest"`
} `json:"mounts"`
}
if json.Unmarshal([]byte(raw), &inventory) != nil || len(inventory.Mounts) != 1 {
return sandbox.ErrInvalid
}
mount := inventory.Mounts[0]
- if config.ExternalWorkspace != (actual.Labels[workspaceModeLabel] == "external") {
- return sandbox.ErrInvalid
- }
- switch actual.Labels[workspaceModeLabel] {
- case "external":
- id, err := uuid.Parse(actual.Labels[workspaceObjectLabel])
- path := actual.Labels[workspacePathLabel]
- if err != nil || id == uuid.Nil || id.String() != actual.Labels[workspaceObjectLabel] || !filepath.IsAbs(path) || filepath.Clean(path) != path || mount.Type != "Bind" || mount.Guest != "/environment" || mount.Host != path {
- return sandbox.ErrInvalid
- }
- return nil
- case "owned":
- if actual.Labels[workspaceObjectLabel] != "" || actual.Labels[workspacePathLabel] != "" || config.EnvironmentDiskMiB == 0 {
- return sandbox.ErrInvalid
- }
- default:
- return sandbox.ErrInvalid
- }
- if mount.Type != "Owned" || mount.Guest != "/environment" || mount.Storage.Kind != "disk" || mount.Storage.CapacityMiB != config.EnvironmentDiskMiB {
+ id, err := uuid.Parse(actual.Labels[workspaceObjectLabel])
+ path := actual.Labels[workspacePathLabel]
+ if err != nil || id == uuid.Nil || id.String() != actual.Labels[workspaceObjectLabel] || !filepath.IsAbs(path) || filepath.Clean(path) != path || mount.Type != "Bind" || mount.Guest != "/environment" || mount.Host != path {
return sandbox.ErrInvalid
}
return nil
@@ -119,16 +96,11 @@ func qualifyRuntimeImage(ctx context.Context, config wire.Config, captured strin
}
func workspaceLabels(workspace *wire.WorkspaceDirectory) map[string]string {
- if workspace == nil {
- return map[string]string{workspaceModeLabel: "owned"}
- }
- return map[string]string{workspaceModeLabel: "external", workspaceObjectLabel: workspace.ObjectID, workspacePathLabel: workspace.Path}
+ return map[string]string{workspaceObjectLabel: workspace.ObjectID, workspacePathLabel: workspace.Path}
}
func qualifyWorkspace(labels map[string]string, workspace *wire.WorkspaceDirectory) error {
- for _, key := range []string{workspaceModeLabel, workspaceObjectLabel} {
- if labels[key] != workspaceLabels(workspace)[key] {
- return sandbox.ErrOwnership
- }
+ if labels[workspaceObjectLabel] != workspace.ObjectID {
+ return sandbox.ErrOwnership
}
return nil
}
diff --git a/services/core/tools/microsandbox-provider/deployment_test.go b/services/core/tools/microsandbox-provider/deployment_test.go
index efdde16d1..3ab23c595 100644
--- a/services/core/tools/microsandbox-provider/deployment_test.go
+++ b/services/core/tools/microsandbox-provider/deployment_test.go
@@ -12,19 +12,30 @@ import (
wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox"
)
+var fixtureWorkspace = &wire.WorkspaceDirectory{Path: "/resolved/environment", ObjectID: "88888888-8888-4888-8888-888888888888"}
+
+// fixtureLabels are the workspace labels of a created compute plus any extras.
+func fixtureLabels(extra map[string]string) map[string]string {
+ labels := workspaceLabels(fixtureWorkspace)
+ for key, value := range extra {
+ labels[key] = value
+ }
+ return labels
+}
+
func deploymentFixture() (wire.Config, map[string]any) {
- config := wire.Config{Image: "runtime@sha256:" + strings.Repeat("a", 64), CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 4096}
+ config := wire.Config{Image: "runtime@sha256:" + strings.Repeat("a", 64), CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192}
actual := map[string]any{
- "labels": map[string]string{workspaceModeLabel: "owned"},
+ "labels": fixtureLabels(nil),
"image": map[string]any{"Oci": map[string]any{"reference": config.Image, "root_disk": map[string]any{"kind": "managed", "size_mib": 8192}}},
"resources": map[string]any{"cpus": 2, "max_cpus": 2, "memory_mib": 2048, "max_memory_mib": 2048},
- "mounts": []any{map[string]any{"type": "Owned", "guest": "/environment", "storage": map[string]any{"kind": "disk", "capacity_mib": 4096}}},
+ "mounts": []any{map[string]any{"type": "Bind", "host": fixtureWorkspace.Path, "guest": "/environment"}},
}
return config, actual
}
func TestNativeDeploymentConfigurationRejectsDrift(t *testing.T) {
- for _, field := range []string{"match", "cpus", "max_cpus", "memory_mib", "max_memory_mib", "image", "root", "environment", "extra_mount", "missing_mount"} {
+ for _, field := range []string{"match", "cpus", "max_cpus", "memory_mib", "max_memory_mib", "image", "root", "mount", "extra_mount", "missing_mount"} {
t.Run(field, func(t *testing.T) {
config, actual := deploymentFixture()
switch field {
@@ -34,8 +45,8 @@ func TestNativeDeploymentConfigurationRejectsDrift(t *testing.T) {
actual["image"] = "runtime:latest"
case "root":
actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = 16384
- case "environment":
- actual["mounts"].([]any)[0].(map[string]any)["storage"].(map[string]any)["capacity_mib"] = 8192
+ case "mount":
+ actual["mounts"].([]any)[0].(map[string]any)["host"] = "/foreign"
case "extra_mount":
actual["mounts"] = append(actual["mounts"].([]any), map[string]any{"type": "Bind", "guest": "/other"})
case "missing_mount":
@@ -55,7 +66,7 @@ func TestRestoredRootRequiresVerifiedInheritedProof(t *testing.T) {
actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = nil
compute := wire.Compute{RestoredFrom: &wire.SnapshotIdentity{ID: "verified-parent"}}
for _, proof := range []string{"", "foreign", resourceProof(config)} {
- actual["labels"] = map[string]string{workspaceModeLabel: "owned", resourceProofLabel: proof}
+ actual["labels"] = fixtureLabels(map[string]string{resourceProofLabel: proof})
raw, _ := json.Marshal(actual)
err := qualifyConfiguration(config, compute, string(raw), false)
if (proof == resourceProof(config)) != (err == nil) {
@@ -74,7 +85,6 @@ func TestSnapshotProofBindsResourcesWithoutChangingCleanupOwnership(t *testing.T
config, actual := deploymentFixture()
ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"}
labels := wire.Labels(config, ref)
- labels[workspaceModeLabel] = "owned"
labels[resourceProofLabel] = resourceProof(config)
if err := qualifySnapshotResources(config, labels); err != nil {
t.Fatal(err)
@@ -85,7 +95,7 @@ func TestSnapshotProofBindsResourcesWithoutChangingCleanupOwnership(t *testing.T
}
actual["labels"] = labels
raw, _ := json.Marshal(actual)
- if _, err := qualifyCompute(config, ref, wire.Compute{ID: "owned"}, "owned", "running", string(raw)); err != nil {
+ if _, err := qualifyCompute(config, ref, wire.Compute{ID: "local:1"}, "local:1", "running", string(raw)); err != nil {
t.Fatal("resource drift changed cleanup ownership", err)
}
if err := qualifySnapshotResources(config, nil); !errors.Is(err, sandbox.ErrInvalid) {
diff --git a/services/core/tools/microsandbox-provider/observed_test.go b/services/core/tools/microsandbox-provider/observed_test.go
index 3acbb2baa..1a23c205f 100644
--- a/services/core/tools/microsandbox-provider/observed_test.go
+++ b/services/core/tools/microsandbox-provider/observed_test.go
@@ -41,7 +41,6 @@ func TestInitialIdentityNeedsAllLabelsAndBootstrapReceipt(t *testing.T) {
config := wire.Config{InstallationID: "installation"}
ref := sandbox.Reference{TenantID: "tenant", EnvironmentID: "environment", AllocationID: "allocation"}
labels := wire.Labels(config, ref)
- labels[workspaceModeLabel] = "owned"
labels[bootstrapLabel] = "pending"
raw, _ := json.Marshal(map[string]any{"labels": labels})
state, e := qualifyCompute(config, ref, wire.Compute{ID: "local:4"}, "local:4", "running", string(raw))
diff --git a/services/core/tools/microsandbox-provider/preparation.go b/services/core/tools/microsandbox-provider/preparation.go
index 2582b3075..9cac07d97 100644
--- a/services/core/tools/microsandbox-provider/preparation.go
+++ b/services/core/tools/microsandbox-provider/preparation.go
@@ -22,7 +22,7 @@ import (
)
// The preparation cache keeps one archive of the prepared /environment
-// directories per Core-supplied key, image digest and workspace mode in a private
+// directories per Core-supplied key and image digest in a private
// directory under the Runtime home. The guest never sees it. An entry is valid
// for preparationTTL after its save.
const preparationTTL = 24 * time.Hour
@@ -36,19 +36,14 @@ var preparedDirectories = []string{"initialization", "packages", "workspace"}
// use; the TTL alone does not, because a busy node can save many keys in a day.
const maxPreparationBytes = 8 << 30
-// The guest archive lives on the root disk, outside /environment, so it is
-// never part of the archive it carries.
-const guestArchive = "/tmp/oac-preparation.tar"
-
func (b backend) preparationStore() string {
return filepath.Join(b.q.Config.RuntimeHome, "preparation")
}
-// preparationEntry names the archive for this key, scoped by the Runtime image
-// digest and by whether /environment is an owned disk or an external directory.
+// preparationEntry names the archive for this key, scoped by the Runtime image digest.
func (b backend) preparationEntry() string {
_, digest, _ := strings.Cut(b.q.Config.Image, "@")
- sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%t|%s", digest, b.q.Config.ExternalWorkspace, b.q.Key)))
+ sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s", digest, b.q.Key)))
return filepath.Join(b.preparationStore(), hex.EncodeToString(sum[:])+".tar")
}
@@ -58,7 +53,7 @@ func validPreparation(path string, now time.Time) bool {
}
// preparationSandbox connects to the running compute and returns the host
-// directory behind /environment, which is empty when the disk is owned.
+// directory behind /environment.
func (b backend) preparationSandbox(ctx context.Context) (*sdk.Sandbox, string, error) {
h, _, err := b.inspect(ctx, b.q.Compute)
if err != nil {
@@ -69,7 +64,7 @@ func (b backend) preparationSandbox(ctx context.Context) (*sdk.Sandbox, string,
return nil, "", err
}
var config sdk.SandboxConfig
- if b.q.Config.ExternalWorkspace && (json.Unmarshal([]byte(h.ConfigJSON()), &config) != nil || config.Labels[workspacePathLabel] == "") {
+ if json.Unmarshal([]byte(h.ConfigJSON()), &config) != nil || config.Labels[workspacePathLabel] == "" {
live.Detach(context.Background())
return nil, "", sandbox.ErrInvalid
}
@@ -106,35 +101,19 @@ func (b backend) restorePreparation(ctx context.Context) (bool, error) {
return false, err
}
defer live.Detach(context.Background())
- if b.q.Config.ExternalWorkspace {
- // The running guest sees the extracted files at once.
- f, err := os.Open(entry)
- if err != nil {
- return false, nil
- }
- defer f.Close()
- err = hostTar(ctx, f, "-C", external, "-xf", "-")
- } else {
- defer b.removeGuestArchive(live)
- // Nothing in /environment has changed until the extraction starts.
- if live.FS().CopyFromHost(ctx, entry, guestArchive) != nil {
- return false, nil
- }
- err = guestRun(ctx, live, "tar --xattrs --numeric-owner -C /environment -xf "+guestArchive+" && sync")
- }
+ // The running guest sees the extracted files at once.
+ f, err := os.Open(entry)
if err != nil {
+ return false, nil
+ }
+ defer f.Close()
+ if err = hostTar(ctx, f, "-C", external, "-xf", "-"); err != nil {
// A failed extraction fails this Session; later Sessions prepare in full.
_ = os.Remove(entry)
}
return err == nil, err
}
-func (b backend) removeGuestArchive(live *sdk.Sandbox) {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- _ = guestRun(ctx, live, "rm -f "+guestArchive)
-}
-
func (b backend) savePreparation(ctx context.Context) error {
ctx, cancel := context.WithDeadline(ctx, b.q.Deadline)
defer cancel()
@@ -147,18 +126,10 @@ func (b backend) savePreparation(ctx context.Context) error {
if err = privateDirectory(store); err != nil {
return err
}
- // The temporary name does not exist yet, so a copy from the guest creates it.
temporary := filepath.Join(store, ".tmp-"+uuid.NewString())
defer os.Remove(temporary)
- if b.q.Config.ExternalWorkspace {
- if err = guestRun(ctx, live, "sync"); err == nil {
- err = hostTar(ctx, nil, append([]string{"-C", external, "-cf", temporary}, preparedDirectories...)...)
- }
- } else {
- defer b.removeGuestArchive(live)
- if err = guestRun(ctx, live, "sync && tar --xattrs --numeric-owner -C /environment -cf "+guestArchive+" "+strings.Join(preparedDirectories, " ")); err == nil {
- err = live.FS().CopyToHost(ctx, guestArchive, temporary)
- }
+ if err = guestRun(ctx, live, "sync"); err == nil {
+ err = hostTar(ctx, nil, append([]string{"-C", external, "-cf", temporary}, preparedDirectories...)...)
}
if err != nil {
return err
diff --git a/services/core/tools/microsandbox-provider/restore_completion_test.go b/services/core/tools/microsandbox-provider/restore_completion_test.go
index 5f0b13a14..95d95d3e8 100644
--- a/services/core/tools/microsandbox-provider/restore_completion_test.go
+++ b/services/core/tools/microsandbox-provider/restore_completion_test.go
@@ -17,7 +17,7 @@ func TestRestoreCompletionRecoversOnlyDerivedProof(t *testing.T) {
config, actual := deploymentFixture()
actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = nil
actual["snapshot_parent"] = "verified-snapshot"
- labels := map[string]string{workspaceModeLabel: "owned"}
+ labels := fixtureLabels(nil)
actual["labels"] = labels
target := wire.Compute{Name: "restored-target", Generation: 1, RestoredFrom: &wire.SnapshotIdentity{ID: "verified-snapshot"}}
reads, writes := 0, 0
@@ -61,12 +61,12 @@ func TestRestoreCompletionRecoversOnlyDerivedProof(t *testing.T) {
}
func TestRestoreCompletionRejectsUnverifiedTargets(t *testing.T) {
- for _, fault := range []string{"cpu", "memory", "environment", "image", "root", "foreign parent", "foreign ID", "foreign proof", "stopped", "starting", "paused", "crashed", "unfinished", "replacement after write", "proof not persisted"} {
+ for _, fault := range []string{"cpu", "memory", "mount", "image", "root", "foreign parent", "foreign ID", "foreign proof", "stopped", "starting", "paused", "crashed", "unfinished", "replacement after write", "proof not persisted"} {
t.Run(fault, func(t *testing.T) {
config, actual := deploymentFixture()
actual["image"].(map[string]any)["Oci"].(map[string]any)["root_disk"].(map[string]any)["size_mib"] = nil
actual["snapshot_parent"] = "verified-snapshot"
- labels := map[string]string{workspaceModeLabel: "owned"}
+ labels := fixtureLabels(nil)
actual["labels"] = labels
target := wire.Compute{Name: "restored-target", ID: "local:restored", Generation: 1, RestoredFrom: &wire.SnapshotIdentity{ID: "verified-snapshot"}}
actualID, status := target.ID, "running"
@@ -75,8 +75,8 @@ func TestRestoreCompletionRejectsUnverifiedTargets(t *testing.T) {
actual["resources"].(map[string]any)["cpus"] = 1
case "memory":
actual["resources"].(map[string]any)["memory_mib"] = 1024
- case "environment":
- actual["mounts"].([]any)[0].(map[string]any)["storage"].(map[string]any)["capacity_mib"] = 1024
+ case "mount":
+ actual["mounts"].([]any)[0].(map[string]any)["host"] = "/foreign"
case "image":
actual["image"] = "runtime:foreign"
case "root":
@@ -126,7 +126,7 @@ func TestRestoreCompletionPreservesInspectionErrors(t *testing.T) {
for _, failedRead := range []int{1, 2} {
config, actual := deploymentFixture()
actual["snapshot_parent"] = "verified-snapshot"
- actual["labels"] = map[string]string{workspaceModeLabel: "owned", resourceProofLabel: resourceProof(config)}
+ actual["labels"] = fixtureLabels(map[string]string{resourceProofLabel: resourceProof(config)})
target := wire.Compute{Name: "restored-target", ID: "local:restored", Generation: 1, RestoredFrom: &wire.SnapshotIdentity{ID: "verified-snapshot"}}
unavailable := errors.New("native inspection unavailable")
reads := 0
diff --git a/services/core/tools/microsandbox-provider/snapshot.go b/services/core/tools/microsandbox-provider/snapshot.go
index 7e89bcb86..ce29aa2b9 100644
--- a/services/core/tools/microsandbox-provider/snapshot.go
+++ b/services/core/tools/microsandbox-provider/snapshot.go
@@ -156,7 +156,7 @@ func (b backend) suspend(ctx context.Context, q wire.SuspendRequest) (wire.State
if json.Unmarshal([]byte(source.ConfigJSON()), &captured) != nil {
return wire.State{}, sandbox.ErrOwnership
}
- for _, key := range []string{workspaceModeLabel, workspaceObjectLabel, workspacePathLabel} {
+ for _, key := range []string{workspaceObjectLabel, workspacePathLabel} {
labels[key] = captured.Labels[key]
}
_, err = sdk.Snapshot.Create(ctx, sdk.SnapshotCreateOptions{
@@ -318,10 +318,8 @@ func (b backend) resume(ctx context.Context, q wire.ResumeRequest) (wire.State,
if q.ObserveOnly || q.Target.ID != "" {
return wire.State{}, wire.ErrUnconfirmed
}
- restore := sdk.RestoreConfig{NetworkPolicy: b.network(), ExternalMountPolicy: sdk.ExternalMountStrict}
- if b.q.Workspace != nil {
- restore.Volumes = map[string]sdk.MountConfig{"/environment": sdk.Mount.Bind(b.q.Workspace.Path, sdk.MountOptions{})}
- }
+ restore := sdk.RestoreConfig{NetworkPolicy: b.network(), ExternalMountPolicy: sdk.ExternalMountStrict,
+ Volumes: map[string]sdk.MountConfig{"/environment": sdk.Mount.Bind(b.q.Workspace.Path, sdk.MountOptions{})}}
if err := b.closeExpiredRestore(q); err != nil {
return wire.State{}, err
}
diff --git a/services/core/tools/microsandbox-provider/workspace_test.go b/services/core/tools/microsandbox-provider/workspace_test.go
index ea726d3f1..220ef4adb 100644
--- a/services/core/tools/microsandbox-provider/workspace_test.go
+++ b/services/core/tools/microsandbox-provider/workspace_test.go
@@ -8,20 +8,14 @@ import (
"testing"
)
-func TestExternalEnvironmentQualificationRequiresExplicitOwnership(t *testing.T) {
- for _, fault := range []string{"match", "missing mode", "foreign path", "owned mount", "wrong guest", "missing object"} {
+func TestEnvironmentQualificationRequiresExplicitOwnership(t *testing.T) {
+ for _, fault := range []string{"match", "foreign path", "owned mount", "wrong guest", "missing object"} {
t.Run(fault, func(t *testing.T) {
config, actual := deploymentFixture()
- config.EnvironmentDiskMiB = 0
- config.ExternalWorkspace = true
- workspace := &wire.WorkspaceDirectory{Path: "/resolved/environment", ObjectID: "88888888-8888-4888-8888-888888888888"}
- labels := workspaceLabels(workspace)
+ labels := workspaceLabels(fixtureWorkspace)
actual["labels"] = labels
- mount := map[string]any{"type": "Bind", "host": workspace.Path, "guest": "/environment"}
- actual["mounts"] = []any{mount}
+ mount := actual["mounts"].([]any)[0].(map[string]any)
switch fault {
- case "missing mode":
- delete(labels, workspaceModeLabel)
case "foreign path":
mount["host"] = "/foreign"
case "owned mount":
@@ -46,9 +40,6 @@ func TestSnapshotWorkspaceIdentityAllowsReresolvedDirectory(t *testing.T) {
if err := qualifyWorkspace(labels, workspace); err != nil {
t.Fatal(err)
}
- if err := qualifyWorkspace(labels, nil); err == nil {
- t.Fatal("external checkpoint accepted owned mode")
- }
workspace.ObjectID = "99999999-9999-4999-8999-999999999999"
if err := qualifyWorkspace(labels, workspace); err == nil {
t.Fatal("foreign object accepted")