diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index f261b3781..0bef73c42 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -86,14 +86,14 @@ function unconfiguredDeployment(generation = 0, ownerEpoch = 3) { } function configuredDeployment() { - return { installation_id: INSTALLATION_ID, provider: "microsandbox", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 } }, specification_digest: "fixture", suspension: { idle_seconds: 300, retention_seconds: 86400 } }; + return { installation_id: INSTALLATION_ID, provider: "microsandbox", credential_configured: false, configuration: {}, metadata: {}, core_url: publicUrl(), reset: null, rollout: nodeRollout(), owner_epoch: 3, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: { resources: { cpus: 2, memory_mib: 4096 }, network: { public: true, domains: [] } }, specification_digest: "fixture", suspension: { idle_seconds: 300, retention_seconds: 86400 } }; } /** The E2B template build as Core read it when the selection was saved. */ const templateBuild = { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 10240 } }; // E2B runs sandboxes in its cloud: no nodes, only what Core holds there. function e2bDeployment() { - return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), suspension: null, resources: { allocations: 3, running: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; + return { ...configuredDeployment(), provider: "e2b", mode: "direct", rollout: noNodeRollout(), suspension: null, resources: { allocations: 3, running: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 }, network: { public: true, domains: [] } }, configuration: { template: "oac-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: templateBuild } }; } function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", installers = true, artifacts = "microsandbox") { @@ -410,7 +410,7 @@ async function sandboxRoute(request, response, path, url) { // As Core: E2B may omit resources and adopt its template build's CPU and memory; only microsandbox suspends. const resources = input.resources ?? { cpus: templateBuild.resources.cpus, memory_mib: templateBuild.resources.memory_mib }; const previous = state.deployment; - const specification = { resources }; + const specification = { resources, network: input.network ?? (initialize ? { public: true, domains: [] } : previous.specification.network) }; const explicitKey = e2b && Object.hasOwn(input, "credential"); const sameSelection = !initialize && JSON.stringify(specification) === JSON.stringify(previous.specification) && (!e2b || input.configuration.template === previous.configuration?.template); // Omission can be a no-op; every explicit key, including identical bytes, diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 46782ce19..634ddde43 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -284,7 +284,7 @@ test("shows the retained E2B build while a replacement key is checked", async ({ }); test("edits only the saved backend, preserving a custom size", async ({ page, request }) => { - const current = { resources: { cpus: 7, memory_mib: 8192, root_disk_mib: 10240 }, workspace: { attachment: "host_directory", user_xattr: true, capacity_quota: false } }; + const current = { resources: { cpus: 7, memory_mib: 8192, root_disk_mib: 10240 }, network: { public: true, domains: [] }, workspace: { attachment: "host_directory", user_xattr: true, capacity_quota: false } }; let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "microsandbox", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, running: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), suspension: null }; diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index ba1e7bf0b..668ee1a31 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -4,12 +4,12 @@ import { deploymentContract, type SandboxProvider, type SandboxSpecification } f describe("deployment resources", () => { it("preserves saved resources only for the same provider", () => { - const current: SandboxSpecification = { resources: { cpus: 7, memory_mib: 8192 } }; + const current: SandboxSpecification = { resources: { cpus: 7, memory_mib: 8192 }, network: { public: true, domains: [] } }; expect(savedSpecification("microsandbox", "microsandbox", current)).toEqual(current); expect(savedSpecification("microsandbox", "microsandbox", current)).not.toBe(current); expect(savedSpecification("e2b", "microsandbox", current)).toBeNull(); expect(savedSpecification("microsandbox", "e2b", current)).toBeNull(); - expect(savedSpecification("e2b", "e2b", { resources: current.resources })).toEqual({ resources: current.resources }); + expect(savedSpecification("e2b", "e2b", { resources: current.resources, network: current.network })).toEqual({ resources: current.resources, network: current.network }); }); it("proposes a copy of each declared default size, which the declared bounds accept", () => { for (const provider of Object.keys(deploymentContract.providers) as SandboxProvider[]) { diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 7c1457196..bff18380d 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -743,6 +743,12 @@ definitions: type: object expected_generation: type: integer + network: + allOf: + - $ref: '#/definitions/sandbox.Network' + description: |- + The network cap. Omitted, it is the public internet only on setup and the + current cap on a change. provider: type: string resources: @@ -763,6 +769,12 @@ definitions: type: object expected_generation: type: integer + network: + allOf: + - $ref: '#/definitions/sandbox.Network' + description: |- + The network cap. Omitted, it is the public internet only on setup and the + current cap on a change. provider: type: string resources: @@ -1947,6 +1959,8 @@ definitions: - DeploymentDirect sandbox.DeploymentSpec: properties: + network: + $ref: '#/definitions/sandbox.Network' resources: $ref: '#/definitions/sandbox.Resources' workspace: @@ -1955,8 +1969,21 @@ definitions: description: Workspace is a derived immutable capability receipt, never filesystem configuration. readOnly: true required: + - network - resources type: object + sandbox.Network: + properties: + domains: + items: + type: string + type: array + public: + type: boolean + required: + - domains + - public + type: object sandbox.NodeDiagnosticCode: enum: - provider_unavailable @@ -7032,7 +7059,7 @@ paths: post: consumes: - application/json - description: Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. + description: Selects a provider, its enforced resource limits and the network cap. An omitted network is the public internet only. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. parameters: - description: Deployment selection in: body @@ -7075,7 +7102,7 @@ paths: put: consumes: - application/json - description: 'Requires the observed generation, the same backend type and no active reset. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal.' + description: 'Requires the observed generation, the same backend type and no active reset. An omitted network keeps the current cap; a changed cap applies to new sandboxes only. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal.' parameters: - description: Replacement deployment selection in: body diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 13e0909d5..f0109f3cd 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -160,6 +160,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the [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)). +- The model provider stays reachable from the sandbox because the Harness runs there; MCP connections from the Environment follow the deployment's [network cap](./sandbox-deployment.md#network). E2B sandboxes cannot reach private networks, and `169.254.169.254` answers inside an E2B sandbox under every cap. - Only `enabled` networking runs, so Sessions that need `disabled` or `restricted` are rejected ([restricted network policy](./environments.md#restricted-network)). - `packages.system` other than null or an empty list is rejected; system packages must be preinstalled. - `Environment.files` lists the files recorded at Session creation; files created later through the Environment files API do not appear. diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index fb79ea726..fa20cc6fb 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -135,6 +135,8 @@ definitions: type: object sandbox.DeploymentSpec: properties: + network: + $ref: '#/definitions/sandbox.Network' resources: $ref: '#/definitions/sandbox.Resources' workspace: @@ -143,8 +145,21 @@ definitions: description: Workspace is a derived immutable capability receipt, never filesystem configuration. readOnly: true required: + - network - resources type: object + sandbox.Network: + properties: + domains: + items: + type: string + type: array + public: + type: boolean + required: + - domains + - public + type: object sandbox.Resources: properties: cpus: diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 83b56bed8..d8a2f2cb9 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -36,6 +36,7 @@ POST and PUT take the same complete selection and require `expected_generation` | `expected_generation` | Required nonnegative integer from GET; never refresh and replay it automatically | | `provider` | Exactly one of `microsandbox`, `e2b` | | `resources` | Per-sandbox limits, below; required for microsandbox, optional for E2B | +| `network` | The egress cap, [below](#network). Omitted on POST means the default; omitted on PUT keeps the current cap | | `configuration` | The provider's public selectors. E2B: the immutable `template` build and the optional paired `api_url` and `domain`. microsandbox accepts only `{}` or omission | | `credential` | The provider's write-only credential. E2B: `{api_key}`, required at first setup and omitted on PUT to keep the current key; a null or empty key is invalid. microsandbox rejects it | @@ -60,6 +61,19 @@ The optional response `specification.workspace` is an immutable capability recei 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. +### Network + +`network` caps what every sandbox of the deployment may reach when its Environment's `network.access` is `enabled` or omitted. The connection to Core and the host of the Session's model provider are always reachable, because the Harness runs inside the sandbox. MCP connections from the Environment follow the cap like any other traffic. + +| Field | Accepted value | +| --- | --- | +| `public` | Boolean. `true` allows public addresses; `false` allows only Core, the model provider and `domains` | +| `domains` | At most 100 exact host names of at most 253 characters, in lowercase letters, digits, hyphens and dots, whose last label starts with a letter. Wildcards, IP literals, ports and URLs are rejected. Core stores them lowercase, deduplicated and sorted. Every address a name resolves to is allowed, private addresses included | + +The default is `{"public": true, "domains": []}`. Under every cap, loopback, link-local and cloud-metadata addresses and all other private addresses stay blocked. Addresses are resolved when a sandbox starts and when it resumes, so a name whose answer changes in between is not followed. A sandbox keeps the cap of the generation that created it. + +Each Provider declares what it enforces ([Sandbox Provider protocol](../../docs/sandbox-provider.md#network-egress)): microsandbox supports both fields, E2B supports `public: false` but not `domains` because E2B sandboxes cannot reach private networks. A cap the selected Provider does not declare returns 400 `invalid_sandbox_configuration` with `param` `network.public` or `network.domains`. + ### E2B configuration E2B uses `configuration.template` in `template-id:build-uuid` form; the build UUID must be canonical and nonzero, and a mutable template alias alone is refused. The API key is encrypted in PostgreSQL and never appears in a response, bootstrap configuration, command argument or log. By default Core uses `https://api.e2b.app` and `e2b.app`. For a compatible service, set both `configuration.api_url` (an HTTPS origin without path, port, query, fragment or credentials) and `configuration.domain` (the sandbox data-plane DNS suffix); the API host must equal the domain or be a subdomain of it. Core rejects a sandbox whose data-plane domain lies outside the selected suffix before sending daemon credentials or using envd. @@ -218,6 +232,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Field | microsandbox | E2B | | --- | --- | --- | | Independent workspace filesystem | Required: [Kernel NFS adapter](../../docs/workspace-provider.md#kernel-nfs-adapter), configured separately from compute | Unsupported | +| Deployment `specification.network` | `public` and `domains` | `public` only; `domains` must be empty and private networks are unreachable | | 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 | | Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved | | Deployment `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`; Core pauses an idle E2B sandbox with the SDK's native pause | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index d03e37261..05ce38941 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: 3e138165ee27672531124d2c156d24e087fb6904e60f828635990dd1d3eb8626 +source_hash: ae9816c683a4f1bbfb09537e0c864767cfa7f376c19ad1c539e91a92bbea447c --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -163,6 +163,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Hosted 工作区准入受[租户容量策略](../../../docs/zh/configuration.md#tenant-capacity)及既有[输入预留截止时间](environments.md#reservations)限制。 - MiniMax Code 未声明 `retained_native_history`,因此无法在 microsandbox 上运行(其每个 Environment 都使用工作区存储);Core 会拒绝该 Session([Harness 能力](./harness-capabilities.md))。 +- 模型提供商从沙箱内始终可达,因为 Harness 运行在那里;Environment 中的 MCP 连接遵循部署的[网络上限](sandbox-deployment.md#network)。E2B 沙箱无法访问私有网络,且在任何上限下 `169.254.169.254` 都会在 E2B 沙箱内应答。 - 只有 `enabled` 网络可以运行,因此需要 `disabled` 或 `restricted` 的 Session 会被拒绝([restricted network policy](environments.md#restricted-network))。 - 除 null 或空列表以外的 `packages.system` 都会被拒绝;系统软件包必须预先安装。 - `Environment.files` 列出 Session 创建时记录的文件;之后通过 Environment files API 创建的文件不会出现。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 8397320ba..88c4bc6b7 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: c16d11a873f0fc39288cd0b1cfbc9514a1392950f46fbaeb49503fa42f80ba9b +source_hash: 7b92833b5d4b9c66df5e93c34dfd58d6be5c8cc1223ba5922abbe31228185525 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -38,6 +38,7 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 ` | `expected_generation` | 必填的非负整数,来自 GET;绝不会自动刷新并重放 | | `provider` | 必须是 `microsandbox`、`e2b` 中恰好一个 | | `resources` | 每个沙箱的限制,见下文;microsandbox 必填,E2B 可选 | +| `network` | 出站上限,见[下文](#network)。POST 中省略表示默认值;PUT 中省略表示保持当前上限 | | `configuration` | 提供商的公开选择器。E2B:不可变的 `template` 构建以及可选且配套的 `api_url` 和 `domain`。microsandbox 仅接受 `{}` 或省略 | | `credential` | 提供商的只写凭据。E2B:`{api_key}`,首次设置时必填,在 PUT 中省略以保留当前密钥;null 或空密钥无效。microsandbox 拒绝该字段 | @@ -63,6 +64,19 @@ microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/enviro 先选择文件系统配置,再配置 microsandbox 部署。选择文件系统不会改变现有代次或 allocation;下一次 Sandbox 更新派生该回执。文件系统选择必须支持提供商声明的要求。现有部署要求相同的 `attachment` 和 `user_xattr`;选择可以更改 `capacity_quota`,此后在 Sandbox 更新设置相匹配的 `environment_disk_mib` 之前拒绝新工作区。既有工作区保持其获准时的容量。 +### 网络 {#network} + +当 Environment 的 `network.access` 为 `enabled` 或省略时,`network` 限定该部署的每个沙箱最多能访问什么。到 Core 的连接和 Session 的模型提供商主机始终可达,因为 Harness 运行在沙箱内。Environment 中的 MCP 连接与其他流量一样遵循该上限。 + +| 字段 | 可接受的值 | +| --- | --- | +| `public` | 布尔值。`true` 允许公开地址;`false` 只允许 Core、模型提供商和 `domains` | +| `domains` | 最多 100 个精确主机名,每个不超过 253 个字符,由小写字母、数字、连字符和点组成,且最后一个标签以字母开头。通配符、IP 字面量、端口和 URL 会被拒绝。Core 以小写、去重并排序的形式存储。名称解析出的每个地址都被允许,包括私有地址 | + +默认值为 `{"public": true, "domains": []}`。无论哪种上限,回环、链路本地、云元数据地址以及其他所有私有地址始终被阻止。地址在沙箱启动和恢复时解析,因此两次解析之间应答变化的名称不会被跟随。沙箱保留创建它的代次的上限。 + +每个 Provider 声明自己能强制执行什么([Sandbox Provider 协议](../../../docs/zh/sandbox-provider.md#network-egress)):microsandbox 支持两个字段,E2B 支持 `public: false` 但不支持 `domains`,因为 E2B 沙箱无法访问私有网络。所选 Provider 未声明的上限会返回 400 `invalid_sandbox_configuration`,`param` 为 `network.public` 或 `network.domains`。 + ### E2B 配置 {#e2b-configuration} E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 UUID 必须是规范形式且非零,仅提供可变模板别名将被拒绝。API 密钥在 PostgreSQL 中加密,绝不会出现在响应、引导配置、命令参数或日志中。默认情况下,Core 使用 `https://api.e2b.app` 和 `e2b.app`。对于兼容服务,请同时设置 `configuration.api_url`(一个不含路径、端口、查询、片段或凭据的 HTTPS 源地址)和 `configuration.domain`(沙箱数据平面 DNS 后缀);API 主机必须等于该域名或其子域。Core 会在发送守护进程凭据或使用 envd 之前,拒绝数据平面域名位于所选后缀之外的沙箱。 @@ -223,6 +237,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。 | 字段 | microsandbox | E2B | | --- | --- | --- | | 独立工作区文件系统 | 必需:[Kernel NFS adapter](../../../docs/zh/workspace-provider.md#kernel-nfs-adapter),与计算资源分开配置 | 不支持 | +| 部署 `specification.network` | `public` 和 `domains` | 仅 `public`;`domains` 必须为空,私有网络不可达 | | 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib`,以及作为配额请求的 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | | 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 | | 部署 `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`;Core 用 SDK 的原生 pause 暂停空闲的 E2B 沙箱 | diff --git a/deploy/node/host_microsandbox.py b/deploy/node/host_microsandbox.py index 0c78c1696..16d79654b 100644 --- a/deploy/node/host_microsandbox.py +++ b/deploy/node/host_microsandbox.py @@ -6,10 +6,8 @@ import os from pathlib import Path import shutil -import socket import stat import subprocess -from urllib.parse import urlsplit import uuid import distribution @@ -142,21 +140,12 @@ def configure(installer, root, args): def native_config(installer, root, args, manifest, runtime_image): - endpoint = urlsplit(args.core_url) - port = endpoint.port or (443 if endpoint.scheme == "https" else 80) - addresses = sorted({entry[4][0] for entry in socket.getaddrinfo(endpoint.hostname, port, type=socket.SOCK_STREAM)}) - core_rules = [{"action": "allow", "direction": "egress", "destination": address, "protocol": "tcp", "port": str(port)} for address in addresses] return { "helper_path": str(root / ARTIFACTS[0]), "runtime_path": str(root / ARTIFACTS[1]), "firmware_path": str(root / ARTIFACTS[2]), "image": manifest["runtime_ref"], "runtime_sha256": manifest["microsandbox"]["runtime_sha256"], "firmware_sha256": manifest["microsandbox"]["firmware_sha256"], "runtime_home": str(home(installer, args.installation_id)), "checkpoint_root": str(checkpoint_root(args)), - "network": {"default_egress": "deny", "default_ingress": "deny", "rules": core_rules + [ - {"action": "allow", "direction": "egress", "destination": "public"}, - {"action": "allow", "direction": "egress", "destination": "host", "protocol": "udp", "port": "53"}, - {"action": "allow", "direction": "egress", "destination": "host", "protocol": "tcp", "port": "53"}, - ]}, } diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index 43c95a15b..0a560fff1 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -24,13 +24,26 @@ 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\":{\"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}") +_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}],\"network\":{\"max_domains\":100,\"max_domain_length\":253,\"domain_pattern\":\"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$\"},\"providers\":{\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"default_resources\":null,\"network\":{\"restrict\":true,\"private_domains\":false}},\"microsandbox\":{\"mode\":\"nodes\",\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true},\"disk\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192},\"network\":{\"restrict\":true,\"private_domains\":true}}},\"minimum_disk\":1024}") # END GENERATED DEPLOYMENT CONTRACT +def canonical_network(provider, network, support, validate): + if validate and (not isinstance(network, dict) or set(network) != {"public", "domains"} or type(network["public"]) is not bool + or not isinstance(network["domains"], list) or not all(isinstance(name, str) for name in network["domains"])): + raise ValueError("Invalid network fields") + rule = _CONTRACT["network"] + domains = sorted({name.strip().lower() for name in network["domains"]}) + if validate and (len(domains) > rule["max_domains"] or not all(len(name) <= rule["max_domain_length"] and re.fullmatch(rule["domain_pattern"], name) for name in domains)): + raise ValueError("Invalid network domains") + if validate and (not network["public"] and not support["restrict"] or domains and not support["private_domains"]): + raise ValueError("Network cap is not supported by " + provider) + return {"public": network["public"], "domains": domains} + + def canonical_spec(provider, specification, validate=True): rules = _CONTRACT["providers"][provider] - if validate and (not isinstance(specification, dict) or set(specification) - {"workspace"} != {"resources"}): + if validate and (not isinstance(specification, dict) or set(specification) - {"workspace"} != {"resources", "network"}): raise ValueError("Invalid specification fields") workspace = specification.get("workspace") requirements = rules.get("workspace") @@ -61,7 +74,7 @@ def canonical_spec(provider, specification, validate=True): raise ValueError("Invalid resource value") if value or not rule["omit_zero"]: ordered[name] = value - result = {"provider": provider, "resources": ordered} + result = {"provider": provider, "resources": ordered, "network": canonical_network(provider, specification["network"], rules["network"], validate)} if workspace is not None: result["workspace"] = {field: workspace[field] if field == "attachment" else bool(workspace.get(field, False)) for field in _CONTRACT["workspace_fields"]} return result @@ -80,7 +93,7 @@ def validate(data, args): if (_CONTRACT["providers"].get(provider, {}).get("mode") != "nodes" or data["installation_id"] != args.installation_id or data["core_url"] != args.core_url or type(data["generation"]) is not int or data["generation"] < 1 or getattr(args, "provider", None) not in (None, provider) - or set(spec) - {"workspace"} != {"resources"}): + or set(spec) - {"workspace"} != {"resources", "network"}): raise ValueError() canonical_spec(provider, spec) if data["specification_digest"] != digest(provider, spec): diff --git a/deploy/node/test_node_generations.py b/deploy/node/test_node_generations.py index 2c1ee0598..d2015a180 100644 --- a/deploy/node/test_node_generations.py +++ b/deploy/node/test_node_generations.py @@ -22,7 +22,7 @@ def setUp(self): self.directory.mkdir(parents=True, mode=0o700) self.value = {"installation_id": "test-installation", "generation": 1, "provider": "microsandbox", "core_url": "https://core.example/api/v1", "native": {"image": "sha256:" + "a" * 64}, - "specification": {"resources": {"cpus": 1, "memory_mib": 1024}}} + "specification": {"resources": {"cpus": 1, "memory_mib": 1024}, "network": {"public": True, "domains": []}}} self.args = SimpleNamespace(installation_id="test-installation", generation=1, specification_digest=node_spec.digest("microsandbox", self.value["specification"])) node_generations.atomic_json(self.root / "provider.json", self.value) self.initialize_lease() @@ -37,7 +37,7 @@ def initialize_lease(self): pass def next_generation(self, resources): - specification = {"resources": resources} + specification = {"resources": resources, "network": {"public": True, "domains": []}} configuration = {"installation_id": "test-installation", "provider": "microsandbox", "core_url": "https://core.example", "generation": 2, "specification": specification, "specification_digest": node_spec.digest("microsandbox", specification)} self.args.generation, self.args.specification_digest = 2, configuration["specification_digest"] return configuration diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index ec7b96b9c..b70b2a62a 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -28,7 +28,6 @@ import node_spec REAL_FETCH = installer.fetch -REAL_GETADDRINFO = socket.getaddrinfo @@ -88,7 +87,6 @@ def setUp(self): mock.patch.object(installer.distribution.urllib.request, "build_opener", return_value=mock.Mock(open=self.artifact_response)), mock.patch.object(host_microsandbox, "home", return_value=self.home / "m"), mock.patch.object(host_microsandbox, "capacity", return_value=(64, 256 << 30)), - mock.patch.object(host_microsandbox.socket, "getaddrinfo", side_effect=lambda host, port, **kwargs: REAL_GETADDRINFO(host, port, **kwargs) if host[0].isdigit() else [(2, 1, 6, "", ("203.0.113.5", port))]), mock.patch.object(installer, "fetch", side_effect=lambda source, name: io.BytesIO(self.payloads[name])), mock.patch.object(installer, "checked", side_effect=self.checked), mock.patch.object(installer.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, b"statically linked", b""))): @@ -102,7 +100,7 @@ def configuration_response(self, request, **kwargs): resources = {"cpus": self.cpus, "memory_mib": 6144} if self.args.provider == "microsandbox": resources.update(root_disk_mib=10240) - spec = {"resources": resources} + spec = {"resources": resources, "network": {"public": True, "domains": []}} 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, @@ -197,16 +195,13 @@ def test_wrong_loaded_runtime_cannot_register_or_write_provider_config(self): self.assertFalse((self.root / 'provider.json').exists()) self.assertFalse(any('register' in call or 'enable' in call for call, _ in self.calls)) - def test_microsandbox_imports_image_and_allows_only_explicit_private_core_endpoint(self): + def test_microsandbox_imports_image_and_writes_no_network_rules(self): self.install() config = json.loads((self.root / "provider.json").read_text())["native"] # The image and artifact hashes come from the installation's verified manifest. - self.assertEqual(set(config), {"helper_path", "runtime_path", "firmware_path", "image", "runtime_sha256", "firmware_sha256", "runtime_home", "checkpoint_root", "network"}) + self.assertEqual(set(config), {"helper_path", "runtime_path", "firmware_path", "image", "runtime_sha256", "firmware_sha256", "runtime_home", "checkpoint_root"}) self.assertEqual((config["image"], config["runtime_sha256"], config["firmware_sha256"]), (self.manifest["runtime_ref"], self.manifest["microsandbox"]["runtime_sha256"], self.manifest["microsandbox"]["firmware_sha256"])) - rules = config["network"]["rules"] - self.assertIn({"action": "allow", "direction": "egress", "destination": "172.29.144.1", "protocol": "tcp", "port": "24443"}, rules) - self.assertNotIn("private", [rule["destination"] for rule in rules]) self.assertTrue(any("--tag" in call and self.manifest["runtime_ref"] in call for call, _ in self.calls)) def test_repeat_preserves_registration_and_recovers_service_start_failure(self): @@ -313,14 +308,13 @@ def test_changed_public_url_clears_unregistered_state_for_a_new_command(self): self.install() self.assertEqual(json.loads((self.root / "registered.json").read_text())["core_url"], "https://core-new.example") - def test_microsandbox_registration_retry_retains_original_dns_policy(self): + def test_microsandbox_registration_retry_retains_original_provider_configuration(self): self.fail_registration = True with self.assertRaises(installer.InstallError): self.install() original = (self.root / "provider.json").read_bytes() self.fail_registration = False - with mock.patch.object(installer.socket, "getaddrinfo", side_effect=OSError("DNS unavailable")): - self.install() + self.install() self.assertEqual((self.root / "provider.json").read_bytes(), original) self.assertTrue((self.root / "registered.json").exists()) diff --git a/deploy/node/test_node_spec.py b/deploy/node/test_node_spec.py index 933447871..de0043ad7 100644 --- a/deploy/node/test_node_spec.py +++ b/deploy/node/test_node_spec.py @@ -14,7 +14,7 @@ class SpecificationTests(unittest.TestCase): def setUp(self): self.args = argparse.Namespace(core_url="https://core.example", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe", provider=None) - self.spec = {"resources": {"cpus": 2, "memory_mib": 4096, "root_disk_mib": 8192}, "workspace": {"attachment": "host_directory", "user_xattr": True}} + self.spec = {"resources": {"cpus": 2, "memory_mib": 4096, "root_disk_mib": 8192}, "network": {"public": True, "domains": []}, "workspace": {"attachment": "host_directory", "user_xattr": True}} self.data = {"installation_id": self.args.installation_id, "provider": "microsandbox", "generation": 3, "specification": self.spec, "specification_digest": node_spec.digest("microsandbox", self.spec), "core_url": self.args.core_url} diff --git a/docs/configuration.md b/docs/configuration.md index 4e266334f..41f162900 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,7 @@ Runtime settings live in Core's database. Change them in Web; scripts use the sa | Sandbox backend: microsandbox or E2B | **Settings** → **Sandbox and scheduling**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per installation, chosen after the first sign-in. Another backend needs **Reset deployment** first; see [change the sandbox configuration](./getting-started/nodes.md#change-the-sandbox-configuration) | | Sandbox size, E2B key and template build | **Settings** → **Sandbox and scheduling** → **Change resources** | `/core/v1/sandbox/deployment` | Web proposes the [default size the Provider declares](./sandbox-provider.md#register-the-provider-kind). Existing sandboxes keep their size. The E2B key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**, **Edit node** and removal | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and [Add and manage nodes](./getting-started/nodes.md) | +| Sandbox network cap | Not in Web | `/core/v1/sandbox/deployment` | `network.public` and `network.domains`; see [Network](../contracts/agents-api/sandbox-deployment.md#network). Existing sandboxes keep the cap they started with | | Idle suspension | **Settings** → **Sandbox and scheduling** → **Automatic suspension** → **Idle before suspension (seconds)** → **Save** | `PATCH /core/v1/sandbox/deployment` | Deployment-wide; see [suspension policy](../contracts/agents-api/sandbox-deployment.md#suspension-policy) | | Independent workspace storage and tenant capacity | **Settings → Workspace storage** | `/core/v1/workspace-storage` | See [Set up workspace storage](./getting-started/workspace-storage.md); existing workspaces retain their configuration | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 1a791b27f..ff1ba194d 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -66,12 +66,12 @@ The node service keeps the proxy settings of the latest run, so it reaches Core | 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/` | | Records | `/etc/oac-node/`: what the installer created or changed, used by reruns and uninstall, and the service's proxy settings | | Runtime image | Imported once into the microsandbox store | -| microsandbox network policy | Sandboxes may reach Core, DNS on the host and public addresses, and nothing else: no inbound connections and no private networks | +| microsandbox network policy | Core applies the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) to every sandbox: no inbound connections, and private networks only for the hosts the cap names | Root only prepares the account, the group and the unit; everything else runs as `oac-node`, in its own session without a terminal. The installer never installs KVM or packages, never changes device permissions, sudoers, firewall or SELinux settings, and never touches other accounts. The node connects out to Core; Core needs no SSH access to the host. > [!WARNING] -> microsandbox sandboxes cannot reach private addresses; see [Sandboxes cannot reach a private endpoint](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint). +> microsandbox sandboxes reach a private address only through a name in the network cap's `domains`; see [Sandboxes cannot reach a private endpoint](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint). **One Core per host.** Nodes share the `oac-node` account, so a host serves one Core; a command from a second Core is refused. diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md index 7f58a99c6..dbadf5ad9 100644 --- a/docs/getting-started/troubleshooting.md +++ b/docs/getting-started/troubleshooting.md @@ -40,9 +40,9 @@ Add node shows no command: see [Before you add a node](./nodes.md#before-you-add ### Sandboxes cannot reach a private endpoint -A microsandbox sandbox cannot reach a model provider or MCP server at a private address, and the Turn fails once the Harness stops retrying. Sandbox DNS queries go to the nameservers listed in the host's `/etc/resolv.conf`, and DNS rebind protection discards any answer that contains a private address. A name those nameservers resolve to a private address therefore does not resolve in the sandbox at all, even for a public service such as a model API behind a cloud VPC resolver. +A microsandbox sandbox reaches a private address only for the model provider's host and for the names in the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) `domains`. Any other name that resolves to a private address does not resolve in the sandbox, because DNS rebind protection discards the answer, and a Turn that depends on it fails once the Harness stops retrying. This includes an MCP server at a private address. -Nodes have no setting to change this. Make those nameservers return the service's public addresses. microsandbox does not read the host's `/etc/hosts`, so an entry there helps only when `/etc/resolv.conf` points at a local stub resolver that serves it, such as systemd-resolved at `127.0.0.53`. +Add the server's exact host name to `domains` with `PUT /core/v1/sandbox/deployment`. The node resolves those names with the nameservers in the host's `/etc/resolv.conf` when a sandbox starts and when it resumes. A name that does not resolve there gets no rule, and the node logs `sandbox network names without an address` with the names. ### Connection failures diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index a6c904eb9..bccb98c11 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -191,6 +191,20 @@ stateDiagram-v2 An allocation moves to `cleanup_pending` when its Session is deleted or archived, its Environment fails or expires, or an administrator resets the deployment. `released` requires `CreateSettled`; see [Identity and resource ownership](#identity-and-resource-ownership). +## Network egress + +A Provider declares `DeploymentPolicy.Network`: `Restrict` means it can refuse public addresses, `PrivateDomains` means it can allow the private addresses that named hosts resolve to. Core validates the deployment's [network cap](../contracts/agents-api/sandbox-deployment.md#network) against the declaration when the selection is saved and never substitutes another behavior. + +Core sends the same `Egress` in `Bootstrap` and in every `ResumeRequest`, derived from the allocation's deployment generation and its Session's frozen model provider: + +- `Public`: public addresses are allowed. +- `PrivateDomains`: exact host names whose resolved addresses, private ones included, are allowed. +- `Endpoints`: the host of the model provider's `base_url`, omitted when it is loopback. The Harness runs in the sandbox and calls it directly. + +The Provider enforces exactly this on top of the always-allowed connection to Core, and blocks loopback, link-local, cloud-metadata and every private address that `Egress` does not name. It resolves names with its host's DNS when it creates and when it resumes, and cannot follow later changes of an answer. A name that does not resolve gets no rule. MCP traffic has no entry: it follows `Public` and `PrivateDomains`. + +microsandbox applies the rules with first-match order, default deny, and host DNS reachable; it allows the resolved private addresses without a port so that DNS rebind protection passes them. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all traffic except the Core host and `Endpoints`. + ## Checkpoint lifecycle A Provider that declares the checkpoint operations (`Initial`, `NewCompute`, `GetCompute`, `RenewCompute`, `Suspend`, `Resume`, `ResumeCompute`, `KillCompute`, `DeleteSnapshot`, `RunCommandCompute`) lets Core suspend an idle allocation to a snapshot and restore it on the same or another compatible node. Core records the allocation's `compute_phase`; it is `disabled` for a Provider that does not declare checkpoint support, and the other phases move as follows. Deletion, archive, reset and retention expiry release the allocation from any phase after the cleanup described in [What Core guarantees to a Provider](#what-core-guarantees-to-a-provider). diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 8a22298b6..988a547b9 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: e06f34fe8629d5de462b5ee1d0f165cb8a278f39872d16a315b04fafbf9467de +source_hash: 61cd106bf8c463b4f4c8e43133e7f292dd366519ff10f5f98597219076bf8971 --- Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类: @@ -62,6 +62,7 @@ Core 安装的每项设置、其归属位置以及修改方式。安装程序写 | 沙箱提供商:microsandbox 或 E2B | **设置** → **沙箱与调度**:设置向导,最后点击 **保存配置** | `/core/v1/sandbox/deployment` | 每个安装只能使用一个沙箱提供商,在首次登录后选择。要改用其他提供商,必须先执行 **重置部署**;请参阅[更改沙箱配置](getting-started/nodes.md#change-the-sandbox-configuration) | | 沙箱大小、E2B 密钥和模板构建 | **设置** → **沙箱与调度** → **修改资源** | `/core/v1/sandbox/deployment` | Web 会推荐 [提供商声明的默认大小](sandbox-provider.md#register-the-provider-kind)。现有沙箱会保留其大小。E2B 密钥仅可写入,并且已加密 | | 节点及其容量 | **节点**:**添加节点**、**编辑节点** 和移除 | `/core/v1/sandbox/enrollment-tokens`、`/core/v1/sandbox/nodes` | 请参阅[节点容量](#node-capacity)和[添加和管理节点](getting-started/nodes.md) | +| 沙箱网络上限 | Web 中没有 | `/core/v1/sandbox/deployment` | `network.public` 和 `network.domains`;参阅[网络](../../contracts/agents-api/zh/sandbox-deployment.md#network)。已有沙箱保持启动时的上限 | | 空闲挂起 | **设置** → **沙箱与调度** → **自动挂起** → **空闲多久后挂起(秒)** → **保存** | `PATCH /core/v1/sandbox/deployment` | 整个部署统一生效;参阅[挂起策略](../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy) | | 独立工作区存储和租户容量 | **设置 → 工作区存储** | `/core/v1/workspace-storage` | 参阅[设置工作区存储](getting-started/workspace-storage.md);现有工作区保留自身配置 | | 项目和 API 密钥 | **项目与密钥**:**创建项目**、**改名**、**签发密钥**、**撤销**、**归档** | `/core/v1/projects` | 密钥只显示一次;Core 存储其摘要 | diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 2cb00fd09..41724019f 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: 43558fa9028c8fa4fff37eada64b9d895f52800c136d3e4f35f185d45e021050 +source_hash: 247ca9c1eff00c067eed15c09a9bd1baaab8f8d7a47d5446644bafe19865b6e3 --- 节点是一台 Linux 主机,为 Core 托管 Session 运行沙箱。本指南面向在沙箱提供商(参阅[选择沙箱提供商](./sandbox-backends.md))为 microsandbox 时添加、修改和移除节点的运维人员;E2B 不需要节点。 @@ -68,13 +68,13 @@ Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添 | 节点状态 | `/var/lib/oac-node/.oac/nodes//`:身份、配置和节点文件。microsandbox 在 `/var/lib/oac-node/.oac/m/` 保存镜像和沙箱 | | 记录 | `/etc/oac-node/`:安装程序创建或修改的内容,用于重新运行和卸载,以及服务的代理设置 | | Runtime 镜像 | 一次性导入 microsandbox 存储 | -| microsandbox 网络策略 | 沙箱仅能访问 Core、主机上的 DNS 和公开地址:无入站连接、不可访问私有网络 | +| microsandbox 网络策略 | Core 对每个沙箱应用部署的[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network):无入站连接,只有上限列出的主机可访问私有网络 | root 只准备账号、组和服务单元;其他操作都以 `oac-node` 在没有终端的独立会话中执行。安装程序不安装 KVM 或软件包,不修改设备权限、sudoers、防火墙或 SELinux 设置,也不修改其他账号。节点向外连接 Core;Core 不需要通过 SSH 访问主机。 > [!CAUTION] > [!WARNING] -> microsandbox 沙箱无法访问私有地址;参见[沙箱无法访问私有端点](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint)。 +> microsandbox 沙箱只能通过网络上限 `domains` 中的名称访问私有地址;参见[沙箱无法访问私有端点](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint)。 **每台主机只服务一个 Core。** 节点共享 `oac-node` 账号,因此主机只服务一个 Core;第二个 Core 的命令会被拒绝。 diff --git a/docs/zh/getting-started/troubleshooting.md b/docs/zh/getting-started/troubleshooting.md index 51e71d346..25369b161 100644 --- a/docs/zh/getting-started/troubleshooting.md +++ b/docs/zh/getting-started/troubleshooting.md @@ -1,7 +1,7 @@ --- title: "故障排查" source: docs/getting-started/troubleshooting.md -source_hash: c2d729e1f6d7a0fab71db8a8183ce84f336fa8386f02b521327afaddebfe2f5e +source_hash: d5191995fac17c30e4d6235b3d5e7d5c91d7d95f8a1600591b5faef8ba7ae3dc --- 按症状查找安装、访问、节点和 Session 问题的原因与解决方法。本页面向 Core 安装的运维人员。 @@ -42,9 +42,9 @@ source_hash: c2d729e1f6d7a0fab71db8a8183ce84f336fa8386f02b521327afaddebfe2f5e ### 沙箱无法访问私有端点 {#sandboxes-cannot-reach-a-private-endpoint} -microsandbox 沙箱无法访问私有地址上的模型提供商或 MCP 服务器,Harness 停止重试后 Turn 失败。沙箱的 DNS 查询发往主机 `/etc/resolv.conf` 中列出的域名服务器,而 DNS rebind 保护会丢弃任何包含私有地址的应答。因此,这些域名服务器解析为私有地址的名称在沙箱内根本无法解析,即使是云 VPC 解析器之后的公共模型 API 这类公共服务也是如此。 +microsandbox 沙箱只能访问模型提供商的主机,以及部署[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network) `domains` 中的名称所对应的私有地址。任何其他解析为私有地址的名称在沙箱内都无法解析,因为 DNS rebind 保护会丢弃该应答,依赖它的 Turn 会在 Harness 停止重试后失败,私有地址上的 MCP 服务器也是如此。 -节点没有更改此行为的设置。请让这些域名服务器返回该服务的公共地址。microsandbox 不读取主机的 `/etc/hosts`,因此只有当 `/etc/resolv.conf` 指向提供该条目的本地存根解析器(例如 `127.0.0.53` 上的 systemd-resolved)时,那里的条目才有用。 +用 `PUT /core/v1/sandbox/deployment` 把该服务器的精确主机名加入 `domains`。节点在沙箱启动和恢复时,用主机 `/etc/resolv.conf` 中的域名服务器解析这些名称。无法解析的名称没有规则,节点会记录 `sandbox network names without an address` 并列出这些名称。 ### 连接失败 {#connection-failures} diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 976903dad..0a169221d 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: e2577eda24eff2656820be8e5aa791b504bddc70399c4329bdd9bb6ad749b858 +source_hash: e485f3a8b9fa811d139227c7117ec2f4807b5487f1d142d074468cea3eeb0639 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox 和 E2B 实现了该协议。 @@ -193,6 +193,20 @@ stateDiagram-v2 当 Session 被删除或归档、Environment 失败或到期,或管理员重置部署时,allocation 进入 `cleanup_pending`。`released` 要求 `CreateSettled`;参见[身份与资源所有权](#identity-and-resource-ownership)。 +## 网络出站 {#network-egress} + +Provider 声明 `DeploymentPolicy.Network`:`Restrict` 表示能拒绝公开地址,`PrivateDomains` 表示能允许具名主机解析出的私有地址。保存选择时,Core 会依据该声明校验部署的[网络上限](../../contracts/agents-api/zh/sandbox-deployment.md#network),绝不替换为其他行为。 + +Core 在 `Bootstrap` 和每个 `ResumeRequest` 中发送同一个 `Egress`,它由分配所属的部署代次和 Session 冻结的模型提供商推导而来: + +- `Public`:允许公开地址。 +- `PrivateDomains`:精确主机名,其解析出的地址(包括私有地址)都被允许。 +- `Endpoints`:模型提供商 `base_url` 的主机,回环主机时省略。Harness 运行在沙箱内,直接调用它。 + +Provider 在始终允许到 Core 的连接之上严格执行这些内容,并阻止回环、链路本地、云元数据以及 `Egress` 未列出的所有私有地址。它在创建和恢复时用主机 DNS 解析名称,无法跟随之后应答的变化。无法解析的名称没有规则。MCP 流量没有条目:它遵循 `Public` 和 `PrivateDomains`。 + +microsandbox 按首条匹配的顺序、默认拒绝并保持主机 DNS 可达来应用规则;它不带端口地允许解析出的私有地址,使 DNS rebind 保护放行它们。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机和 `Endpoints` 之外的全部流量。 + ## 检查点生命周期 {#checkpoint-lifecycle} 声明了 checkpoint 操作(`Initial`、`NewCompute`、`GetCompute`、`RenewCompute`、`Suspend`、`Resume`、`ResumeCompute`、`KillCompute`、`DeleteSnapshot`、`RunCommandCompute`)的提供商,使 Core 能把空闲 allocation 暂停为快照,并在同一或另一个兼容 node 上恢复。Core 记录 allocation 的 `compute_phase`;未声明 checkpoint 支持的提供商为 `disabled`,其余阶段按下图变化。删除、归档、重置和保留期到期会在完成[Core 对提供商的保证](#what-core-guarantees-to-a-provider)中描述的清理后,从任意阶段释放 allocation。 diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 24fce195e..70bd913e7 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":{"e2b":{"mode":"direct","disk":false,"default_resources":null,"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"cautions":[],"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"setup":{"label":"microsandbox","summary":{"en":"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.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"cautions":[],"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}}},"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}],"network":{"max_domains":100,"max_domain_length":253,"domain_pattern":"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$"},"providers":{"e2b":{"mode":"direct","disk":false,"default_resources":null,"network":{"restrict":true,"private_domains":false},"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"cautions":[],"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"network":{"restrict":true,"private_domains":true},"setup":{"label":"microsandbox","summary":{"en":"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.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"cautions":[],"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 4dae6fc7c..8f8da1c37 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -212,11 +212,12 @@ export interface DeploymentResources { } export const deploymentResourcesFields = ["allocations", "pending", "running"] as const; export interface DeploymentSpec { + network: Network; resources: SandboxResources; workspace?: Declaration; } -export const deploymentSpecFields = ["resources", "workspace"] as const; -export const deploymentSpecRequired = ["resources"] as const; +export const deploymentSpecFields = ["network", "resources", "workspace"] as const; +export const deploymentSpecRequired = ["network", "resources"] as const; export interface DeploymentView { configuration?: Record; core_url: string; @@ -439,6 +440,11 @@ export interface ModelConfigurationSupport { protocols: string[]; } export const modelConfigurationSupportFields = ["accepts_harness_config", "accepts_max_output_tokens", "protocols"] as const; +export interface Network { + domains: string[]; + public: boolean; +} +export const networkFields = ["domains", "public"] as const; export interface Node { active: number; available_disk_bytes: number | null; @@ -815,19 +821,21 @@ export interface SandboxDeploymentChangeInput { configuration?: Record; credential?: Record; expected_generation: number; + network?: Network; provider?: string; resources?: SandboxResources; } -export const sandboxDeploymentChangeInputFields = ["configuration", "credential", "expected_generation", "provider", "resources"] as const; +export const sandboxDeploymentChangeInputFields = ["configuration", "credential", "expected_generation", "network", "provider", "resources"] as const; export const sandboxDeploymentChangeInputRequired = ["expected_generation"] as const; export interface SandboxDeploymentInput { configuration?: Record; credential?: Record; expected_generation: number; + network?: Network; provider?: string; resources?: SandboxResources; } -export const sandboxDeploymentInputFields = ["configuration", "credential", "expected_generation", "provider", "resources"] as const; +export const sandboxDeploymentInputFields = ["configuration", "credential", "expected_generation", "network", "provider", "resources"] as const; export const sandboxDeploymentInputRequired = ["expected_generation"] as const; export interface SandboxEnrollmentToken { enrollment_id: string; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 710e2a0e3..e40917252 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -48,14 +48,14 @@ const unconfigured = { const microsandbox = { configuration: {}, metadata: {}, ...unconfigured, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "microsandbox", rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", - resources: { allocations: 2, running: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192 } }, specification_digest: "0".repeat(64), + resources: { allocations: 2, running: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192 }, network: { public: true, domains: [] } }, specification_digest: "0".repeat(64), suspension: { idle_seconds: 300, retention_seconds: 86400 }, }; const externalWorkspace = { ...deploymentContract.providers.microsandbox.workspace, capacity_quota: false }; const externalDeployment = { ...microsandbox, specification: { ...microsandbox.specification, workspace: externalWorkspace } }; /** An E2B selection saved before Core recorded its template build. */ const e2bDeployment = { - ...microsandbox, provider: "e2b", rollout: unconfigured.rollout, mode: "direct", specification: { resources: { cpus: 2, memory_mib: 2048 } }, + ...microsandbox, provider: "e2b", rollout: unconfigured.rollout, mode: "direct", specification: { resources: { cpus: 2, memory_mib: 2048 }, network: { public: true, domains: [] } }, configuration: { template: "runtime:00000000-0000-0000-0000-000000000001", api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: { status: null, resources: { cpus: null, memory_mib: null, root_disk_mib: null } } }, }; const reads: Record Promise> = { diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 578760864..1ad53aec2 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -8,7 +8,7 @@ import { hostHistoryFields, hostHistoryPointFields, nodeAllocationDiagnosticValues, nodeAllocationFields, nodeDetailFields, capacityFields, configurationOptionFields, configurationOptionRequired, configurationOptionsFields, nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, resetFields, resetOfflineNodeFields, resetRemainingFields, rolloutFields, rolloutNodesFields, rolloutStateValues, - sandboxAllocationListFields, sandboxNodeListFields, sandboxResourcesFields, sandboxResourcesRequired, suspensionFields, + sandboxAllocationListFields, sandboxNodeListFields, networkFields, sandboxResourcesFields, sandboxResourcesRequired, suspensionFields, type ConfigurationOption, type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, type NodeHost, type NodeRollout, type NodeUpdate, type Reset, type ResetOfflineNode, type ResetRequest, type Rollout, type SandboxIdlePolicyInput, type SandboxDeploymentInput, type SandboxEnrollmentToken, type SandboxResources as SandboxResourcesResource, @@ -102,7 +102,9 @@ function projectSpecification(value: unknown): SandboxSpecification { const specification = members(value, deploymentSpecFields, deploymentSpecRequired); const resources = members(specification.resources, sandboxResourcesFields, sandboxResourcesRequired); valid(Object.values(resources).every(isNonnegativeInteger)); - const result: SandboxSpecification = { resources: { ...resources } as unknown as SandboxResources }; + const network = members(specification.network, networkFields, networkFields); + valid(typeof network.public === "boolean" && Array.isArray(network.domains) && network.domains.every((name) => typeof name === "string")); + const result: SandboxSpecification = { resources: { ...resources } as unknown as SandboxResources, network: { public: network.public as boolean, domains: [...(network.domains as string[])] } }; if (hasOwn(specification, "workspace")) { const workspace = members(specification.workspace, deploymentContract.workspace_fields); if (!isOneOf(attachmentKindValues, workspace.attachment) || typeof workspace.user_xattr !== "boolean" || typeof workspace.capacity_quota !== "boolean") return invalidSandboxResponse(); diff --git a/services/core/cmd/sandbox-node/generations_test.go b/services/core/cmd/sandbox-node/generations_test.go index e2847837a..2b765b962 100644 --- a/services/core/cmd/sandbox-node/generations_test.go +++ b/services/core/cmd/sandbox-node/generations_test.go @@ -100,7 +100,7 @@ func TestGenerationHelperKeepsCancellation(t *testing.T) { // Preparation may resolve native state, such as the digest a containerd image // store names the loaded Runtime image by; the envelope stays fixed. func TestGenerationPlanFixesOnlyTheEnvelope(t *testing.T) { - plan := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "microsandbox", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Native: json.RawMessage(`{"image":"sha256:` + strings.Repeat("b", 64) + `"}`)} + plan := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "microsandbox", Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Native: json.RawMessage(`{"image":"sha256:` + strings.Repeat("b", 64) + `"}`)} final := plan final.Native = json.RawMessage(`{"image":"sha256:` + strings.Repeat("c", 64) + `"}`) if !sameGenerationPlan(final, plan) { diff --git a/services/core/cmd/server/managed_generations_test.go b/services/core/cmd/server/managed_generations_test.go index 5f0d7cf3e..aff7a5bc3 100644 --- a/services/core/cmd/server/managed_generations_test.go +++ b/services/core/cmd/server/managed_generations_test.go @@ -36,7 +36,7 @@ print(json.dumps({'Version':2,'Info':info})) } paths := testProviderPaths(t, helper, state) id := uuid.NewString() - old := deployment.Setup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, UsesCredential: true, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} + old := deployment.Setup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, UsesCredential: true, Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "old-key", Template: "old:" + uuid.NewString()}} current := old current.Generation = 2 current.Specification.Resources.CPUs = 4 @@ -145,7 +145,7 @@ print(json.dumps(result)) paths := testProviderPaths(t, helper, state) id, build := uuid.NewString(), ":"+uuid.NewString() current := deployment.Setup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, UsesCredential: true, - Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, + Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: tc.committedKey, Template: tc.committedTemplate + build}} committed := current setups := &fakeDeploymentSetups{t: t, setup: committedSetup(&committed), withCredential: credentialService(t), diff --git a/services/core/cmd/server/managed_setup_preflight_test.go b/services/core/cmd/server/managed_setup_preflight_test.go index e810fc2e4..cb1a0b1e0 100644 --- a/services/core/cmd/server/managed_setup_preflight_test.go +++ b/services/core/cmd/server/managed_setup_preflight_test.go @@ -29,7 +29,7 @@ func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) { paths := testProviderPaths(t, helper, state) id := uuid.NewString() s := &managedSetup{processPaths: paths, registry: providers.Builtin(), installationID: id} - selection := deployment.Setup{InstallationID: id, Provider: "e2b", Mode: "direct", UsesCredential: true, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} + selection := deployment.Setup{InstallationID: id, Provider: "e2b", Mode: "direct", UsesCredential: true, Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 3, MemoryMiB: 3072}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} _, err := s.prepare(t.Context(), selection) if !errors.Is(err, sandbox.ErrConfigurationSelection) || strings.Contains(err.Error(), "synthetic-private-key") || s.selected.Load() != nil { t.Fatal("rejected candidate lost its safe diagnostic or was published", err) @@ -66,7 +66,7 @@ else: paths := testProviderPaths(t, helper, state) id := uuid.NewString() selection := deployment.Setup{InstallationID: id, Provider: "e2b", Mode: "direct", Generation: 1, UsesCredential: true, - Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, + Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}, Configuration: &e2b.DeploymentConfiguration{APIKey: "synthetic-private-key", Template: "runtime:" + uuid.NewString()}} allocation := func(context.Context, sandbox.Reference) (deployment.Setup, error) { return selection, nil } s := &managedSetup{processPaths: paths, registry: providers.Builtin(), installationID: id, diff --git a/services/core/internal/api/core_store_validation_test.go b/services/core/internal/api/core_store_validation_test.go index 1ca6f5ade..bbe14f1a5 100644 --- a/services/core/internal/api/core_store_validation_test.go +++ b/services/core/internal/api/core_store_validation_test.go @@ -33,7 +33,7 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { } upperCapacityErr := nodes.UpdateNode(context.Background(), managementProjectID, deployment.NodeUpdate{Name: "node", MaxActive: 1000001, MaxRetained: 8}) capacityErr := nodes.UpdateNode(context.Background(), managementProjectID, deployment.NodeUpdate{Name: "node", MaxActive: 0}) - _, resourceErr := nodes.SetupForSelection(managementProjectID, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: sandbox.DeploymentSpec{Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}}) + _, resourceErr := nodes.SetupForSelection(managementProjectID, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}}) for _, tc := range []struct { err error code, param string diff --git a/services/core/internal/api/sandbox_deployment_changes_test.go b/services/core/internal/api/sandbox_deployment_changes_test.go index 77c090ea3..ba3ddaf9f 100644 --- a/services/core/internal/api/sandbox_deployment_changes_test.go +++ b/services/core/internal/api/sandbox_deployment_changes_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -106,3 +107,34 @@ func TestSandboxMutationErrorsExposeOnlyTypedCoreFacts(t *testing.T) { } } } + +// An omitted network is the default cap and is marked so an update keeps the +// current one; a submitted network reaches the selection with its domains. +func TestSandboxDeploymentNetworkSelection(t *testing.T) { + deps, fakes := sandboxFakes(t) + var got sandbox.Selection + fakes.deploymentChanges.updateSandboxDeployment = func(_ context.Context, in sandbox.Selection) (deployment.View, error) { + got = in + return deployment.View{Provider: in.Provider}, nil + } + fakes.deployment.decodeConfiguration = providers.Builtin().DecodeInput + h := newTestHandler(t, deps) + const base = `{"provider":"e2b","expected_generation":2,"credential":{"api_key":"synthetic-private-key"},"configuration":{"template":"qualified:build"}` + for _, tc := range []struct { + body string + network sandbox.Network + omitted bool + }{ + {base + `}`, sandbox.DefaultNetwork(), true}, + {base + `,"network":{"public":false,"domains":null}}`, sandbox.Network{Domains: []string{}}, false}, + {base + `,"network":{"public":true,"domains":["gitlab.example.com"]}}`, sandbox.Network{Public: true, Domains: []string{"gitlab.example.com"}}, false}, + } { + r := httptest.NewRequest("PUT", "/core/v1/sandbox/deployment", strings.NewReader(tc.body)) + r.Header.Set("Authorization", "Bearer administrator") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || !reflect.DeepEqual(got.Network, tc.network) || got.NetworkOmitted != tc.omitted { + t.Fatalf("%s: status=%d network=%+v omitted=%v", tc.body, w.Code, got.Network, got.NetworkOmitted) + } + } +} diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 4aa58c496..8b0d2617f 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -17,10 +17,13 @@ type SandboxDeploymentInput struct { // Per-sandbox limits, required for a Provider that declares default // resources. Another Provider may omit them; Core then uses the cpus and // memory_mib of the configuration it validated. - Resources sandbox.Resources `json:"resources"` - Provider string `json:"provider"` - Configuration json.RawMessage `json:"configuration" swaggertype:"object"` - Credential json.RawMessage `json:"credential,omitempty" swaggertype:"object"` + Resources sandbox.Resources `json:"resources"` + // The network cap. Omitted, it is the public internet only on setup and the + // current cap on a change. + Network *sandbox.Network `json:"network,omitempty"` + Provider string `json:"provider"` + Configuration json.RawMessage `json:"configuration" swaggertype:"object"` + Credential json.RawMessage `json:"credential,omitempty" swaggertype:"object"` } type SandboxDeploymentChangeInput struct { @@ -33,7 +36,15 @@ func (h *Handler) selection(v SandboxDeploymentInput) (sandbox.Selection, error) if err != nil { return sandbox.Selection{}, err } - return sandbox.Selection{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources}, Configuration: c}, nil + selection := sandbox.Selection{ExpectedGeneration: *v.ExpectedGeneration, Provider: v.Provider, DeploymentSpec: sandbox.DeploymentSpec{Resources: v.Resources, Network: sandbox.DefaultNetwork()}, Configuration: c} + if v.Network != nil { + selection.Network = *v.Network + if selection.Network.Domains == nil { + selection.Network.Domains = []string{} + } + } + selection.NetworkOmitted = v.Network == nil + return selection, nil } // DeploymentChanges sets up and updates the sandbox deployment through the @@ -51,7 +62,7 @@ type DeploymentReset interface { } // @Summary Initialize the deployment sandbox provider -// @Description Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. +// @Description Selects a provider, its enforced resource limits and the network cap. An omitted network is the public internet only. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -66,7 +77,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req return } var input SandboxDeploymentInput - if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "expected_generation") != nil || input.ExpectedGeneration == nil { + if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "network", "expected_generation") != nil || input.ExpectedGeneration == nil { writeDeploymentError(w, r, deployment.ErrInvalidInput) return } @@ -84,7 +95,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req } // @Summary Change the sandbox deployment configuration -// @Description Requires the observed generation, the same backend type and no active reset. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. +// @Description Requires the observed generation, the same backend type and no active reset. An omitted network keeps the current cap; a changed cap applies to new sandboxes only. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -99,7 +110,7 @@ func (h *Handler) updateSandboxDeployment(w http.ResponseWriter, r *http.Request return } var input SandboxDeploymentChangeInput - if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "expected_generation") != nil || input.ExpectedGeneration == nil { + if decodeInputObject(raw, &input, "provider", "configuration", "credential", "resources", "network", "expected_generation") != nil || input.ExpectedGeneration == nil { writeDeploymentError(w, r, deployment.ErrInvalidInput) return } diff --git a/services/core/internal/deployment/execution.go b/services/core/internal/deployment/execution.go index 437173dfc..77c66b4ac 100644 --- a/services/core/internal/deployment/execution.go +++ b/services/core/internal/deployment/execution.go @@ -147,6 +147,9 @@ func (e *ExecutionOperations) ClassifyChange(ctx context.Context, installation s if err != nil { return err } + if input.NetworkOmitted && previous.Provider != "" { + input.Network = previous.Specification.Network + } resolved, err := e.service.registry.ResolveChange(input, previous.selection()) if err != nil { return configurationError(err) diff --git a/services/core/internal/deployment/service_test.go b/services/core/internal/deployment/service_test.go index e35d25a2c..1ce0dbf72 100644 --- a/services/core/internal/deployment/service_test.go +++ b/services/core/internal/deployment/service_test.go @@ -60,7 +60,7 @@ func operations(t *testing.T, publicURL string, tx *fakeDeploymentTx) *Execution // testSpecification is a valid deployment specification for provider. func testSpecification(provider string) sandbox.DeploymentSpec { - s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} + s := sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} if provider == "microsandbox" { s.Resources.RootDiskMiB = 8192 s.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true} @@ -146,7 +146,7 @@ func TestUnknownProviderIsRejectedBeforeStorage(t *testing.T) { func TestSandboxValidationWrapperPreservesClassification(t *testing.T) { service := newService(t, &fakeStorage{t: t}, &fakeReader{t: t}, testPublicURL) installation := "00000000-0000-4000-8000-000000000001" - _, err := service.SetupForSelection(installation, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: sandbox.DeploymentSpec{Workspace: testSpecification("microsandbox").Workspace}}) + _, err := service.SetupForSelection(installation, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Workspace: testSpecification("microsandbox").Workspace}}) var configuration *ConfigurationError if !errors.As(err, &configuration) || !errors.Is(err, ErrInvalidInput) || configuration.Validation == nil || configuration.Validation.Param != "resources.cpus" { t.Fatalf("missing validation metadata: %#v", err) diff --git a/services/core/internal/execution/network_egress.go b/services/core/internal/execution/network_egress.go new file mode 100644 index 000000000..d0c729426 --- /dev/null +++ b/services/core/internal/execution/network_egress.go @@ -0,0 +1,48 @@ +package execution + +import ( + "context" + "errors" + "net/netip" + "net/url" + "strings" + + "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/sessions" +) + +// allocationEgress derives what the allocation may reach from its immutable +// inputs only: the deployment generation's network cap and the model provider +// the Session froze at creation. Create and Resume send the same value. +func (r *runtimeLifecycle) allocationEgress(ctx context.Context, owner deployment.Allocation, network sandbox.Network) (sandbox.Egress, error) { + provider, err := r.sessions.SessionModelExecution(ctx, owner.TenantID, owner.SessionID) + if errors.Is(err, sessions.ErrNotFound) { + return egressFor(network, ""), nil + } + if err != nil { + return sandbox.Egress{}, err + } + return egressFor(network, provider.BaseURL), nil +} + +// egressFor applies the cap. The Harness runs inside the sandbox, so the +// host of the Session's model provider is always reachable; a loopback host is +// the sandbox itself and needs no entry. +func egressFor(network sandbox.Network, modelBaseURL string) sandbox.Egress { + egress := sandbox.Egress{Public: network.Public, PrivateDomains: append([]string{}, network.Domains...), Endpoints: []string{}} + if u, err := url.Parse(modelBaseURL); err == nil { + if host := strings.ToLower(u.Hostname()); host != "" && !loopbackHost(host) { + egress.Endpoints = append(egress.Endpoints, host) + } + } + return egress +} + +func loopbackHost(host string) bool { + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip, err := netip.ParseAddr(host) + return err == nil && ip.IsLoopback() +} diff --git a/services/core/internal/execution/network_egress_test.go b/services/core/internal/execution/network_egress_test.go new file mode 100644 index 000000000..d1f129cd0 --- /dev/null +++ b/services/core/internal/execution/network_egress_test.go @@ -0,0 +1,29 @@ +package execution + +import ( + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestEgressFollowsTheCapAndTheModelProviderHost(t *testing.T) { + cap := sandbox.Network{Public: false, Domains: []string{"gitlab.example.com"}} + for _, tc := range []struct { + baseURL string + want sandbox.Egress + }{ + {"https://API.Model.example:8443/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Endpoints: []string{"api.model.example"}}}, + {"http://10.1.2.3:8000/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Endpoints: []string{"10.1.2.3"}}}, + {"http://127.0.0.1:8000/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Endpoints: []string{}}}, + {"http://localhost:8000", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Endpoints: []string{}}}, + {"", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Endpoints: []string{}}}, + } { + if got := egressFor(cap, tc.baseURL); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("%q: %+v, want %+v", tc.baseURL, got, tc.want) + } + } + if got := egressFor(sandbox.DefaultNetwork(), ""); !got.Public || len(got.PrivateDomains) != 0 { + t.Fatalf("default cap: %+v", got) + } +} diff --git a/services/core/internal/execution/runtime_compute.go b/services/core/internal/execution/runtime_compute.go index e3f2b2be7..a76034294 100644 --- a/services/core/internal/execution/runtime_compute.go +++ b/services/core/internal/execution/runtime_compute.go @@ -376,8 +376,12 @@ func (r *runtimeLifecycle) restoreCompute(ctx context.Context, p sandbox.Sandbox return err } } + egress, err := r.allocationEgress(ctx, owner, spec.Network) + if err != nil { + return err + } started := time.Now() - result, err := p.Resume(ctx, sandbox.ResumeRequest{Workspace: workspace, Reference: runtimeReference(owner), OperationID: state.RestoreID, Snapshot: *state.Snapshot, Target: *state.Target, ObserveOnly: observeOnly}) + result, err := p.Resume(ctx, sandbox.ResumeRequest{Workspace: workspace, Reference: runtimeReference(owner), OperationID: state.RestoreID, Snapshot: *state.Snapshot, Target: *state.Target, Egress: egress, ObserveOnly: observeOnly}) logComputeStep(ctx, owner, "resume", started, err) if errors.Is(err, sandbox.ErrCheckpointUnusable) { // The Provider closed this attempt before native dispatch. Never restore diff --git a/services/core/internal/execution/runtime_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go index 2265f6484..d2317425c 100644 --- a/services/core/internal/execution/runtime_lifecycle.go +++ b/services/core/internal/execution/runtime_lifecycle.go @@ -273,11 +273,16 @@ func (r *runtimeLifecycle) provision(ctx context.Context, tenant, environment, p if err := r.lease.CheckOwnership(ctx); err != nil { return owner, err } + egress, err := r.allocationEgress(ctx, owner, spec.Network) + if err != nil { + return owner, err + } started := time.Now() info, err := provider.Create(ctx, sandbox.Bootstrap{ Reference: runtimeReference(owner), SessionID: owner.SessionID, DeviceID: owner.DeviceID, Workspace: workspace, CoreURL: r.config.CoreURL, Credential: token, SuspendControl: sandbox.SupportsCheckpoint(provider.ProviderOperations()), + Egress: egress, }) logComputeStep(ctx, owner, "create", started, err) if info.Reference == runtimeReference(owner) && info.CreateSettled && info.State == "absent" { diff --git a/services/core/internal/execution/runtime_replacement_test.go b/services/core/internal/execution/runtime_replacement_test.go index ae4edbc0f..48d96c32c 100644 --- a/services/core/internal/execution/runtime_replacement_test.go +++ b/services/core/internal/execution/runtime_replacement_test.go @@ -332,7 +332,7 @@ func workspaceNodeSetup(t *testing.T) func(Owner, *deployment.Service, deploymen if err := owner.Deployment.Claim(t.Context(), installation); err != nil { t.Fatal(err) } - specification := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}} + specification := sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}} view, err := owner.Deployment.Initialize(t.Context(), installation, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: specification}) if err != nil { t.Fatal(err) diff --git a/services/core/internal/execution/sandbox_deployment_setup_test.go b/services/core/internal/execution/sandbox_deployment_setup_test.go index 9666a1602..fa57ce298 100644 --- a/services/core/internal/execution/sandbox_deployment_setup_test.go +++ b/services/core/internal/execution/sandbox_deployment_setup_test.go @@ -142,7 +142,7 @@ func TestRejectedSandboxCandidatePreservesActiveGeneration(t *testing.T) { if err != nil { t.Fatal(err) } - input := sandbox.Selection{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} + input := sandbox.Selection{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}} if _, err := m.prepareCandidate(t.Context(), input); !errors.Is(err, rejected) { t.Fatal("candidate rejection was lost", err) } @@ -172,7 +172,7 @@ func TestSandboxCandidateValidationDoesNotHoldManagerLock(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _, _ = m.prepareCandidate(t.Context(), sandbox.Selection{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) + _, _ = m.prepareCandidate(t.Context(), sandbox.Selection{Provider: "e2b", Configuration: &e2b.DeploymentConfiguration{APIKey: "fixture-key", Template: "runtime:" + uuid.NewString()}, DeploymentSpec: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 1024}}}) }() <-entered stopped := make(chan struct{}) diff --git a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go index 9237131a6..ec66ccd8d 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go @@ -94,7 +94,7 @@ func admin(t *testing.T) context.Context { // testSpecification is a valid deployment specification for provider. func testSpecification(provider string) sandbox.DeploymentSpec { - s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} + s := sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} if provider == "microsandbox" { s.Resources.RootDiskMiB = 8192 s.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true} diff --git a/services/core/internal/persistence/postgres/deploymentpg/generations_test.go b/services/core/internal/persistence/postgres/deploymentpg/generations_test.go index f1b0e713c..c6114c54e 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/generations_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/generations_test.go @@ -302,3 +302,18 @@ func TestRejectedChangeLeavesDeploymentUnchanged(t *testing.T) { t.Fatal("committed change was not audited", audit) } } + +func TestOmittedNetworkKeepsTheStoredCap(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + input := setupE2BSelection() + input.Network = sandbox.Network{Public: false, Domains: []string{}} + id, view := f.initialize(t, changes, input) + omitted := setupE2BSelection() + omitted.Network, omitted.NetworkOmitted, omitted.ExpectedGeneration = sandbox.Network{}, true, view.Generation + omitted.Configuration = &e2b.DeploymentConfiguration{Template: input.Configuration.(*e2b.DeploymentConfiguration).Template} + resolved, _, err := changes.ClassifyChange(t.Context(), id, omitted) + if err != nil || resolved.Network.Public || resolved.Network.Domains == nil { + t.Fatalf("omitted network did not keep the stored cap: %+v %v", resolved.Network, err) + } +} diff --git a/services/core/internal/persistence/postgres/sessionpg/creation_test.go b/services/core/internal/persistence/postgres/sessionpg/creation_test.go index eaa2c4c65..f3001517b 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation_test.go @@ -473,7 +473,7 @@ func TestHostedCreationAdmitsUnderDeploymentLockAndLeavesComputeUnreserved(t *te ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() tenant := uuid.NewString() - nodes, err := json.Marshal(sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 1024}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}) + nodes, err := json.Marshal(sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 1024}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index 5ef002c60..7ecd14f83 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -75,6 +75,7 @@ func (r Resources) ValidatePolicy(provider string, rules DeploymentPolicy) error type DeploymentSpec struct { Resources Resources `json:"resources" binding:"required"` + Network Network `json:"network" binding:"required"` // Workspace is a derived immutable capability receipt, never filesystem configuration. Workspace *workspacefs.Declaration `json:"workspace,omitempty" readonly:"true"` } @@ -93,6 +94,9 @@ func (s DeploymentSpec) ValidatePolicy(provider string, policy DeploymentPolicy) return err } } + if err := s.Network.Canonical().ValidatePolicy(provider, policy.Network); err != nil { + return err + } return s.Resources.ValidatePolicy(provider, policy) } diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index eeb2b27e1..bd8ef32e2 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -74,9 +74,10 @@ func DeploymentContract(providers map[string]ProviderProjection) (python, typesc raw, _ := json.Marshal(struct { WorkspaceFields []string `json:"workspace_fields"` Resources []resourceRule `json:"resources"` + Network networkRule `json:"network"` Providers map[string]ProviderProjection `json:"providers"` MinimumDisk uint32 `json:"minimum_disk"` - }{workspaceFields, resourceContract, providers, minimumDiskMiB}) + }{workspaceFields, resourceContract, networkContract, providers, minimumDiskMiB}) return string(raw) } installer := make(map[string]ProviderProjection, len(providers)) diff --git a/services/core/internal/sandbox/e2b/deployment.go b/services/core/internal/sandbox/e2b/deployment.go index 12cca7661..fb7ac8166 100644 --- a/services/core/internal/sandbox/e2b/deployment.go +++ b/services/core/internal/sandbox/e2b/deployment.go @@ -12,7 +12,7 @@ import ( ) func Policy() sandbox.DeploymentPolicy { - return sandbox.DeploymentPolicy{} + return sandbox.DeploymentPolicy{Network: sandbox.NetworkSupport{Restrict: true}} } // Setup declares how the console connects E2B and compatible services. The diff --git a/services/core/internal/sandbox/e2b/helper_contract_test.go b/services/core/internal/sandbox/e2b/helper_contract_test.go index 76c2dcd18..46b3da3db 100644 --- a/services/core/internal/sandbox/e2b/helper_contract_test.go +++ b/services/core/internal/sandbox/e2b/helper_contract_test.go @@ -67,6 +67,7 @@ func validManagedExchange(data []byte) bool { delete(expected, "CoreURL") delete(expected, "Credential") delete(expected, "SuspendControl") + delete(expected, "Egress") expected["InstallationID"] = nil expected["RuntimeBootstrap"] = nil if workspace, present := fields["Workspace"]; present { diff --git a/services/core/internal/sandbox/e2b/internal/contractgen/main.go b/services/core/internal/sandbox/e2b/internal/contractgen/main.go index aaa39d639..fedb13550 100644 --- a/services/core/internal/sandbox/e2b/internal/contractgen/main.go +++ b/services/core/internal/sandbox/e2b/internal/contractgen/main.go @@ -67,9 +67,9 @@ func main() { write("services/core/internal/sandbox/e2b/helper_sdk_generated.go", goCode) bootstrap := fields(reflect.TypeFor[sandbox.Bootstrap]()) requiredBootstrap := fieldNames(reflect.TypeFor[sandbox.Bootstrap](), true) - requiredBootstrap = slices.DeleteFunc(requiredBootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" || s == "SuspendControl" }) + requiredBootstrap = slices.DeleteFunc(requiredBootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" || s == "SuspendControl" || s == "Egress" }) requiredBootstrap = append(requiredBootstrap, "InstallationID", "RuntimeBootstrap") - bootstrap = slices.DeleteFunc(bootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" || s == "SuspendControl" }) + bootstrap = slices.DeleteFunc(bootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" || s == "SuspendControl" || s == "Egress" }) bootstrap = append(bootstrap, "InstallationID", "RuntimeBootstrap") identity := fields(reflect.TypeFor[sandbox.Reference]()) for _, name := range []string{"SessionID", "DeviceID"} { @@ -198,6 +198,7 @@ func fixtures() []byte { delete(managed, "CoreURL") delete(managed, "Credential") delete(managed, "SuspendControl") + delete(managed, "Egress") managed["InstallationID"] = installation managed["RuntimeBootstrap"] = runtimebootstrap.Connection(connection) add("managed", "valid", true, object(managed)) diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go index fbdea40b6..741d7e5c2 100644 --- a/services/core/internal/sandbox/microsandbox/deployment.go +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -7,7 +7,8 @@ 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}} + DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192}, + Network: sandbox.NetworkSupport{Restrict: true, PrivateDomains: true}} } // Setup declares how the console connects microsandbox nodes. diff --git a/services/core/internal/sandbox/microsandbox/identity.go b/services/core/internal/sandbox/microsandbox/identity.go index c9becac36..bb98b1acd 100644 --- a/services/core/internal/sandbox/microsandbox/identity.go +++ b/services/core/internal/sandbox/microsandbox/identity.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net/url" "path/filepath" "strings" @@ -33,17 +34,9 @@ func (c Config) Validate() error { if !pinned || imageName == "" || strings.Contains(imageName, "@") || !validHash(digest) { return sandbox.ErrInvalid } - if c.Network.DefaultEgress != "allow" && c.Network.DefaultEgress != "deny" { + if u, err := url.Parse(c.CoreURL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { return sandbox.ErrInvalid } - if c.Network.DefaultIngress != "allow" && c.Network.DefaultIngress != "deny" { - return sandbox.ErrInvalid - } - for _, r := range c.Network.Rules { - if (r.Action != "allow" && r.Action != "deny") || (r.Direction != "egress" && r.Direction != "ingress") || r.Destination == "" { - return sandbox.ErrInvalid - } - } return nil } func Name(c Config, r sandbox.Reference, g uint64) string { @@ -116,7 +109,7 @@ func ValidateRequest(q Request) error { } switch q.Operation { case "create": - if q.Workspace == nil || (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 || q.Bootstrap.Egress.Validate() != nil { return sandbox.ErrInvalid } case "initial_info": @@ -147,7 +140,7 @@ func ValidateRequest(q Request) error { } case "resume": v := q.Resume - 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 { + 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 || v.Egress.Validate() != nil { 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 50b864f7a..22a03b67f 100644 --- a/services/core/internal/sandbox/microsandbox/node.go +++ b/services/core/internal/sandbox/microsandbox/node.go @@ -21,48 +21,29 @@ var NodeArtifacts = []providerassets.Artifact{ // Native is the node configuration's native object for microsandbox: the // helper, Runtime and firmware paths, the installation's verified Runtime image -// and artifact hashes, the private store and the network policy. The helper owns +// and artifact hashes and the private store. The helper owns // local paths; no ambient backend is selected. Resources come from the // deployment specification. type Native struct { - HelperPath string `json:"helper_path"` - RuntimeHome string `json:"runtime_home"` - CheckpointRoot string `json:"checkpoint_root"` - RuntimePath string `json:"runtime_path"` - FirmwarePath string `json:"firmware_path"` - Image string `json:"image"` - RuntimeSHA256 string `json:"runtime_sha256"` - FirmwareSHA256 string `json:"firmware_sha256"` - Network Network `json:"network"` + HelperPath string `json:"helper_path"` + RuntimeHome string `json:"runtime_home"` + CheckpointRoot string `json:"checkpoint_root"` + RuntimePath string `json:"runtime_path"` + FirmwarePath string `json:"firmware_path"` + Image string `json:"image"` + RuntimeSHA256 string `json:"runtime_sha256"` + FirmwareSHA256 string `json:"firmware_sha256"` } -type Network struct { - DefaultEgress string `json:"default_egress"` - DefaultIngress string `json:"default_ingress"` - Rules []Rule `json:"rules"` -} - -type Rule struct { - Action string `json:"action"` - Direction string `json:"direction"` - Destination string `json:"destination"` - Protocol string `json:"protocol"` - Port string `json:"port"` -} - -func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, caller *ProcessCaller, result *sandbox.Built, options sandbox.LocalOptions) (Config, error) { +func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, coreURL string, caller *ProcessCaller, result *sandbox.Built, options sandbox.LocalOptions) (Config, error) { if !filepath.IsAbs(entry.RuntimeHome) || filepath.Clean(entry.RuntimeHome) != entry.RuntimeHome { return Config{}, errors.New("managed microsandbox runtime_home must be a canonical absolute path") } - network := NetworkPolicy{DefaultEgress: entry.Network.DefaultEgress, DefaultIngress: entry.Network.DefaultIngress} - for _, rule := range entry.Network.Rules { - network.Rules = append(network.Rules, NetworkRule{Action: rule.Action, Direction: rule.Direction, Destination: rule.Destination, Protocol: rule.Protocol, Port: rule.Port}) - } resources := spec.Resources config := Config{ 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, Network: network, + MemoryMiB: resources.MemoryMiB, CPUs: uint8(resources.CPUs), RootDiskMiB: resources.RootDiskMiB, CoreURL: coreURL, } provider, err := NewWithCaller(config, caller) if err != nil { @@ -90,7 +71,7 @@ func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, caller *Pr func BuildNode(c sandbox.NodeConfig, options sandbox.LocalOptions, result *sandbox.Built) (func(), error) { closeProvider := func() {} var entry Native - if sandbox.DecodeConfigurationObject(c.Native, &entry, "helper_path", "runtime_home", "runtime_path", "firmware_path", "image", "runtime_sha256", "firmware_sha256", "network", "checkpoint_root") != nil { + if sandbox.DecodeConfigurationObject(c.Native, &entry, "helper_path", "runtime_home", "runtime_path", "firmware_path", "image", "runtime_sha256", "firmware_sha256", "checkpoint_root") != nil { return closeProvider, errors.New("invalid managed microsandbox node configuration") } caller := &ProcessCaller{} @@ -106,7 +87,7 @@ func BuildNode(c sandbox.NodeConfig, options sandbox.LocalOptions, result *sandb caller.LeasePath = filepath.Join(directory, strconv.FormatUint(c.Generation, 10)+".lease") caller.LeaseIdentity = LeaseIdentity{InstallationID: result.InstallationID, Generation: c.Generation, SpecificationDigest: result.SpecificationDigest} } - config, err := configureMicrosandbox(entry, c.Specification, caller, result, options) + config, err := configureMicrosandbox(entry, c.Specification, c.CoreURL, caller, result, options) if err != nil { return closeProvider, err } diff --git a/services/core/internal/sandbox/microsandbox/node_test.go b/services/core/internal/sandbox/microsandbox/node_test.go index 656bb567e..892eb8ef3 100644 --- a/services/core/internal/sandbox/microsandbox/node_test.go +++ b/services/core/internal/sandbox/microsandbox/node_test.go @@ -25,7 +25,7 @@ func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") digest := sha256.Sum256(script) native := Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, CheckpointRoot: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), - Image: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:]), Network: Network{DefaultEgress: "allow", DefaultIngress: "deny"}} + Image: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:])} if err := os.Chmod(dir, 0700); err != nil { t.Fatal(err) } @@ -36,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}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}, Native: raw} + config := sandbox.NodeConfig{Provider: "microsandbox", CoreURL: "https://core.example.com", Generation: 9, Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), 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/process.go b/services/core/internal/sandbox/microsandbox/process.go index 47898ad60..fd5ac3285 100644 --- a/services/core/internal/sandbox/microsandbox/process.go +++ b/services/core/internal/sandbox/microsandbox/process.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "io" "os" @@ -68,6 +69,10 @@ func (p *ProcessCaller) Call(ctx context.Context, q Request) (Response, error) { return Response{}, errors.New("helper result unconfirmed") } } + // The helper reports only host names that had no address on stderr. + if stderr.Len() > 0 { + log.Bg().Warn("sandbox network names without an address", "operation", q.Operation, "names", stderr.String()) + } var out Response decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) decoder.DisallowUnknownFields() diff --git a/services/core/internal/sandbox/microsandbox/provider.go b/services/core/internal/sandbox/microsandbox/provider.go index 0053b5295..192e2b2c6 100644 --- a/services/core/internal/sandbox/microsandbox/provider.go +++ b/services/core/internal/sandbox/microsandbox/provider.go @@ -23,7 +23,6 @@ func NewWithCaller(c Config, caller Caller) (*Provider, error) { if c.Validate() != nil || caller == nil { return nil, sandbox.ErrInvalid } - c.Network.Rules = append([]NetworkRule(nil), c.Network.Rules...) return &Provider{config: c, caller: caller}, nil } func (p *Provider) Initial(ctx context.Context, r sandbox.Reference) (Compute, error) { diff --git a/services/core/internal/sandbox/microsandbox/provider_test.go b/services/core/internal/sandbox/microsandbox/provider_test.go index 04ba9abf1..cc4a29d0d 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, Network: NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny", Rules: []NetworkRule{{Action: "allow", Direction: "egress", Destination: "host"}}}, + MemoryMiB: 2048, CPUs: 2, RootDiskMiB: 4096, CoreURL: "https://core.example.com", } } func testRef() sandbox.Reference { diff --git a/services/core/internal/sandbox/microsandbox/types.go b/services/core/internal/sandbox/microsandbox/types.go index c2bc43434..7d5913b37 100644 --- a/services/core/internal/sandbox/microsandbox/types.go +++ b/services/core/internal/sandbox/microsandbox/types.go @@ -16,7 +16,7 @@ const MaxRequestBytes = 72 * 1024 * 1024 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. +// immutable, qualified installation. CoreURL is the one address every allocation may always reach. type Config struct { InstallationID string HelperPath string @@ -30,16 +30,9 @@ type Config struct { MemoryMiB uint32 CPUs uint8 RootDiskMiB uint32 - Network NetworkPolicy + CoreURL string } -type NetworkPolicy struct { - DefaultEgress string - DefaultIngress string - Rules []NetworkRule -} -type NetworkRule struct{ Action, Direction, Destination, Protocol, Port string } - // These aliases keep the helper wire private while Core uses provider-neutral types. type Compute = sandbox.Compute type SnapshotIdentity = sandbox.SnapshotIdentity diff --git a/services/core/internal/sandbox/network.go b/services/core/internal/sandbox/network.go new file mode 100644 index 000000000..8ac9f1c2b --- /dev/null +++ b/services/core/internal/sandbox/network.go @@ -0,0 +1,97 @@ +package sandbox + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// The deployment network rule is authored here once. The node installer and the +// TypeScript client consume its generated projection. +const ( + maxNetworkDomains = 100 + maxDomainLength = 253 + // domainPattern accepts an exact host name: dot-separated RFC 1123 labels + // whose last label starts with a letter, which also rejects IP literals. + domainPattern = `^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$` +) + +var domainRule = regexp.MustCompile(domainPattern) + +// networkRule is the generated projection of the network format rule. +type networkRule struct { + MaxDomains int `json:"max_domains"` + MaxDomainLength int `json:"max_domain_length"` + DomainPattern string `json:"domain_pattern"` +} + +var networkContract = networkRule{maxNetworkDomains, maxDomainLength, domainPattern} + +// Network is the deployment's egress cap: what its sandboxes may reach at most. +// Domains are exact host names whose resolved addresses are allowed, private +// ones included. Every other private, loopback and link-local address stays +// blocked, and the connection to Core is always allowed. +type Network struct { + Public bool `json:"public" binding:"required"` + Domains []string `json:"domains" binding:"required"` +} + +// DefaultNetwork is the public internet only. +func DefaultNetwork() Network { return Network{Public: true, Domains: []string{}} } + +// Canonical lowercases, deduplicates and sorts the domains. It is applied +// before persistence so equal caps have equal specification digests. +func (n Network) Canonical() Network { + domains := make([]string, 0, len(n.Domains)) + for _, d := range n.Domains { + domains = append(domains, strings.ToLower(strings.TrimSpace(d))) + } + slices.Sort(domains) + n.Domains = slices.Compact(domains) + return n +} + +// ValidatePolicy checks the cap against the Provider's declaration. +func (n Network) ValidatePolicy(provider string, support NetworkSupport) error { + if len(n.Domains) > maxNetworkDomains { + return &ValidationError{Param: "network.domains", Message: fmt.Sprintf("%s: network.domains accepts at most %d domains", ErrInvalid, maxNetworkDomains)} + } + for _, d := range n.Domains { + if len(d) > maxDomainLength || !domainRule.MatchString(d) { + return &ValidationError{Param: "network.domains", Message: fmt.Sprintf("%s: %q is not an exact host name", ErrInvalid, d)} + } + } + if !n.Public && !support.Restrict { + return &ValidationError{Param: "network.public", Message: fmt.Sprintf("%s: %s cannot refuse public addresses", ErrInvalid, provider)} + } + if len(n.Domains) > 0 && !support.PrivateDomains { + return &ValidationError{Param: "network.domains", Message: fmt.Sprintf("%s: %s cannot allow private domains", ErrInvalid, provider)} + } + return nil +} + +// Validate checks an Egress a Provider receives: exact host names for +// PrivateDomains and non-empty host names or IP addresses for Endpoints. +func (e Egress) Validate() error { + if len(e.PrivateDomains) > maxNetworkDomains { + return ErrInvalid + } + for _, d := range e.PrivateDomains { + if len(d) > maxDomainLength || !domainRule.MatchString(d) { + return ErrInvalid + } + } + for _, h := range e.Endpoints { + if h == "" || len(h) > maxDomainLength || strings.ContainsAny(h, "/ \t\r\n") { + return ErrInvalid + } + } + return nil +} + +// ValidDomain reports whether name is an exact host name under the deployment +// network rule. +func ValidDomain(name string) bool { + return len(name) <= maxDomainLength && domainRule.MatchString(name) +} diff --git a/services/core/internal/sandbox/network_test.go b/services/core/internal/sandbox/network_test.go new file mode 100644 index 000000000..53fad7e80 --- /dev/null +++ b/services/core/internal/sandbox/network_test.go @@ -0,0 +1,37 @@ +package sandbox + +import "testing" + +func TestNetworkIsValidatedAgainstTheProviderDeclaration(t *testing.T) { + none, closed, full := NetworkSupport{}, NetworkSupport{Restrict: true}, NetworkSupport{Restrict: true, PrivateDomains: true} + for _, tc := range []struct { + name string + network Network + support NetworkSupport + param string + }{ + {"default", DefaultNetwork(), none, ""}, + {"closed", Network{Domains: []string{}}, closed, ""}, + {"closed without support", Network{Domains: []string{}}, none, "network.public"}, + {"domains", Network{Public: true, Domains: []string{"gitlab.example.com"}}, full, ""}, + {"domains without support", Network{Public: true, Domains: []string{"gitlab.example.com"}}, closed, "network.domains"}, + {"wildcard", Network{Public: true, Domains: []string{"*.example.com"}}, full, "network.domains"}, + {"address", Network{Public: true, Domains: []string{"10.0.0.1"}}, full, "network.domains"}, + } { + err := tc.network.ValidatePolicy("test", tc.support) + var invalid *ValidationError + if tc.param == "" && err != nil || tc.param != "" && (!asValidation(err, &invalid) || invalid.Param != tc.param) { + t.Fatalf("%s: %v", tc.name, err) + } + } + got := Network{Domains: []string{"B.example.com ", "a.example.com", "b.example.com"}}.Canonical().Domains + if len(got) != 2 || got[0] != "a.example.com" || got[1] != "b.example.com" { + t.Fatalf("canonical domains: %v", got) + } +} + +func asValidation(err error, target **ValidationError) bool { + v, ok := err.(*ValidationError) + *target = v + return ok +} diff --git a/services/core/internal/sandbox/node/workspace_test.go b/services/core/internal/sandbox/node/workspace_test.go index 519f57de6..0136dc221 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{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{CoreURL: "https://core.example.com", 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} provider, err := microsandbox.NewWithCaller(config, caller) if err != nil { t.Fatal(err) diff --git a/services/core/internal/sandbox/providers/config_test.go b/services/core/internal/sandbox/providers/config_test.go index 92e97e123..f79c1f779 100644 --- a/services/core/internal/sandbox/providers/config_test.go +++ b/services/core/internal/sandbox/providers/config_test.go @@ -27,7 +27,7 @@ func TestNodeRejectsCoreConfigurationAndUnknownProvider(t *testing.T) { func TestNodeBuildRequiresNodeProviderAndGeneration(t *testing.T) { registry := Builtin() options := sandbox.LocalOptions{Standalone: true} - if _, _, err := registry.Build(sandbox.NodeConfig{Generation: 1, Provider: "e2b", InstallationID: uuid.NewString(), Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}}, options); err == nil { + if _, _, err := registry.Build(sandbox.NodeConfig{Generation: 1, Provider: "e2b", InstallationID: uuid.NewString(), Specification: sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}}}, options); err == nil { t.Fatal("accepted a cloud provider on a node") } if _, _, err := registry.Build(sandbox.NodeConfig{Provider: "microsandbox", InstallationID: uuid.NewString(), Specification: validRegistrationSpec()}, options); err == nil { diff --git a/services/core/internal/sandbox/providers/configuration.go b/services/core/internal/sandbox/providers/configuration.go index fd8b08c99..4b3897ed7 100644 --- a/services/core/internal/sandbox/providers/configuration.go +++ b/services/core/internal/sandbox/providers/configuration.go @@ -74,6 +74,7 @@ func (r *Registry) Normalize(s sandbox.Selection) (sandbox.Selection, error) { if e != nil { return s, e } + s.Network = s.Network.Canonical() return a.Configuration.Normalize(s) } func (r *Registry) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { @@ -81,6 +82,7 @@ func (r *Registry) ResolveChange(next, previous sandbox.Selection) (sandbox.Sele if e != nil { return next, e } + next.Network = next.Network.Canonical() return a.Configuration.ResolveChange(next, previous) } func (r *Registry) WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) { diff --git a/services/core/internal/sandbox/providers/deployment_contract_test.go b/services/core/internal/sandbox/providers/deployment_contract_test.go index 2f9d9798e..60c87af01 100644 --- a/services/core/internal/sandbox/providers/deployment_contract_test.go +++ b/services/core/internal/sandbox/providers/deployment_contract_test.go @@ -56,6 +56,7 @@ func TestDeploymentContractFixtures(t *testing.T) { if !fixture.Valid { return } + spec.Network = spec.Network.Canonical() canonical, _ := json.Marshal(struct { Provider string `json:"provider"` sandbox.DeploymentSpec diff --git a/services/core/internal/sandbox/providers/generation_test.go b/services/core/internal/sandbox/providers/generation_test.go index 5dd51c02b..f522a308d 100644 --- a/services/core/internal/sandbox/providers/generation_test.go +++ b/services/core/internal/sandbox/providers/generation_test.go @@ -25,13 +25,12 @@ func generationConfig(t *testing.T) (sandbox.NodeConfig, sandboxmicro.Native) { 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"}} + Image: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} raw, err := json.Marshal(native) if err != nil { t.Fatal(err) } - return sandbox.NodeConfig{Provider: "microsandbox", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, Native: raw}, native + return sandbox.NodeConfig{Provider: "microsandbox", CoreURL: "https://core.example.com", InstallationID: uuid.NewString(), Generation: 9, Specification: spec, Native: raw}, native } func TestMicrosandboxGenerationDirectoryOwnership(t *testing.T) { diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 17fb3f831..58eea2796 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -14,6 +14,7 @@ import ( func validRegistrationSpec() sandbox.DeploymentSpec { return sandbox.DeploymentSpec{ + Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 1024}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}, } diff --git a/services/core/internal/sandbox/providers/validation_test.go b/services/core/internal/sandbox/providers/validation_test.go index a98dd5f8f..f17d0228f 100644 --- a/services/core/internal/sandbox/providers/validation_test.go +++ b/services/core/internal/sandbox/providers/validation_test.go @@ -22,7 +22,7 @@ func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { {"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}, {"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)}, } { - spec := sandbox.DeploymentSpec{Resources: tc.resources} + spec := sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: tc.resources} if tc.provider == "microsandbox" { // microsandbox runs every sandbox on workspace storage. spec.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true, CapacityQuota: true} @@ -39,11 +39,11 @@ func TestDeploymentValidationFieldsPreserveMessages(t *testing.T) { } } } - if err := registry.ValidateSpecification("microsandbox", sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 255, MemoryMiB: 1048576, RootDiskMiB: 1024}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}); err != nil { + if err := registry.ValidateSpecification("microsandbox", sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 255, MemoryMiB: 1048576, RootDiskMiB: 1024}, Workspace: &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true}}); err != nil { t.Fatal(err) } var field *sandbox.ValidationError - err := registry.ValidateSpecification("private-provider", sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 1, MemoryMiB: 512}}) + err := registry.ValidateSpecification("private-provider", sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 1, MemoryMiB: 512}}) if errors.As(err, &field) || err.Error() != sandbox.ErrInvalid.Error()+": unsupported sandbox provider" { t.Fatal("unknown provider reclassified", err) } diff --git a/services/core/internal/sandbox/providers/workspace_test.go b/services/core/internal/sandbox/providers/workspace_test.go index fe0fbb231..a782916e3 100644 --- a/services/core/internal/sandbox/providers/workspace_test.go +++ b/services/core/internal/sandbox/providers/workspace_test.go @@ -25,7 +25,7 @@ func TestWorkspaceReceiptValidatesNormalizationAndGenerationBuild(t *testing.T) return func() {}, reached } registry.adapters["microsandbox"] = adapter - config := sandbox.NodeConfig{Provider: "microsandbox", InstallationID: "11111111-1111-4111-8111-111111111111", Generation: 1, Specification: spec} + config := sandbox.NodeConfig{Provider: "microsandbox", CoreURL: "https://core.example.com", InstallationID: "11111111-1111-4111-8111-111111111111", Generation: 1, Specification: spec} _, _, err := registry.Build(config, sandbox.LocalOptions{Standalone: true, Workspace: unreachableWorkspaceResolver{}}) if !errors.Is(err, reached) { t.Fatal("external retained generation could not build", err) diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index c54875eb3..2343ee37d 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -64,6 +64,25 @@ type Bootstrap struct { SessionID, DeviceID, CoreURL, Credential string // SuspendControl is true exactly when the Provider declares checkpoint support. SuspendControl bool + // Egress is what the allocation may reach besides its Core connection. + Egress Egress +} + +// Egress is what one allocation may reach besides its Core connection. Core +// derives it from the allocation's deployment generation and the Session's +// frozen configuration, and sends the same value at Create and at every Resume. +// The Provider enforces exactly it and always blocks loopback, link-local +// (cloud metadata) and every private address that Egress does not name. +type Egress struct { + // Public allows every public address. + Public bool `json:"public"` + // PrivateDomains are exact host names the Provider resolves with the host's + // DNS; every address they resolve to is allowed, private ones included. + PrivateDomains []string `json:"private_domains"` + // Endpoints are the host names or IP addresses of the Session's model + // provider. The Harness runs inside the sandbox and calls them directly, so + // every address they resolve to is allowed, private ones included. + Endpoints []string `json:"endpoints"` } // Info describes compute only. Running does not establish daemon authentication, @@ -246,6 +265,8 @@ type ResumeRequest struct { OperationID string Snapshot SnapshotIdentity Target Compute + // Egress is the value Core sent at Create; the restored sandbox runs under it. + Egress Egress // Recovery observes the previous target and never starts a new restore. ObserveOnly bool } @@ -402,11 +423,25 @@ func (d SetupDeclaration) Validate(r ConfigurationRequirements) error { // 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. +// proposes, or nil when the Provider's configuration selects it. Network +// declares the egress restrictions the Provider enforces. type DeploymentPolicy struct { Workspace *workspacefs.Requirements `json:"workspace,omitempty"` Disk bool `json:"disk"` DefaultResources *Resources `json:"default_resources"` + Network NetworkSupport `json:"network"` +} + +// NetworkSupport declares the egress restrictions a Provider enforces. Every +// Provider always allows its Core connection and blocks loopback and link-local +// addresses. +type NetworkSupport struct { + // Restrict: can refuse public addresses (Egress.Public false) while still + // allowing Core and the Egress hosts that resolve to public addresses. + Restrict bool `json:"restrict"` + // PrivateDomains: can allow the private addresses that Egress.PrivateDomains + // and Egress.Endpoints resolve to, and blocks every other private address. + PrivateDomains bool `json:"private_domains"` } // Configuration is an adapter-owned typed value, never a request or response DTO. @@ -428,6 +463,8 @@ type ConfigurationRecord struct { // Selection is the typed deployment configuration shared by preview and commit. type Selection struct { DeploymentSpec + // NetworkOmitted marks a change that left network out; it keeps the current cap. + NetworkOmitted bool `json:"-"` ExpectedGeneration uint64 `json:"expected_generation"` Provider string `json:"provider"` Configuration Configuration `json:"-"` diff --git a/services/core/internal/sandbox/testdata/deployment-contract.json b/services/core/internal/sandbox/testdata/deployment-contract.json index ec4376b90..eba213c34 100644 --- a/services/core/internal/sandbox/testdata/deployment-contract.json +++ b/services/core/internal/sandbox/testdata/deployment-contract.json @@ -13,11 +13,15 @@ "attachment": "host_directory", "user_xattr": true, "capacity_quota": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": true, - "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" + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295},\"network\":{\"public\":true,\"domains\":[]},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":true}}", + "digest": "5681b8aff93025b98b8f1d539cc5427612ae03cf7ce933d3950366c7ac4a15f8" }, { "name": "e2b", @@ -26,11 +30,15 @@ "resources": { "cpus": 1, "memory_mib": 512 + }, + "network": { + "public": true, + "domains": [] } }, "valid": true, - "canonical": "{\"provider\":\"e2b\",\"resources\":{\"cpus\":1,\"memory_mib\":512}}", - "digest": "bfedd8252478cfeb7e927ccd9aab6e812e0a35405046db3cf023fe62a869de04" + "canonical": "{\"provider\":\"e2b\",\"resources\":{\"cpus\":1,\"memory_mib\":512},\"network\":{\"public\":true,\"domains\":[]}}", + "digest": "bfb113d499a2982999297b0831c47f98ccf11faead0fd5f9f6b31a4dc7d80809" }, { "name": "cpu-zero", @@ -44,6 +52,10 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -60,6 +72,10 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -76,6 +92,10 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -92,6 +112,10 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -109,6 +133,10 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -127,6 +155,10 @@ "attachment": "host_directory", "user_xattr": true, "capacity_quota": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -146,6 +178,10 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -163,11 +199,15 @@ "attachment": "host_directory", "user_xattr": true, "capacity_quota": false + }, + "network": { + "public": true, + "domains": [] } }, "valid": true, - "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":false}}", - "digest": "cd31758a8ec9df729120eccd3db650d0f82cb35c4fbd1b1f5b7cf70140ae9faf" + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024},\"network\":{\"public\":true,\"domains\":[]},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":false}}", + "digest": "79d6a3f68ac1fcc4e4ad4f11edc2f9e3c7f03858c1a29392c2f8097950577b8a" }, { "name": "microsandbox-external-requires-root", @@ -182,6 +222,10 @@ "attachment": "host_directory", "user_xattr": true, "capacity_quota": false + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -200,6 +244,10 @@ "attachment": "host_directory", "user_xattr": true, "capacity_quota": false + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -217,6 +265,10 @@ "attachment": "host_directory", "user_xattr": false, "capacity_quota": false + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -229,6 +281,10 @@ "cpus": 255, "memory_mib": 1048576, "root_disk_mib": 1024 + }, + "network": { + "public": true, + "domains": [] } }, "valid": false @@ -245,11 +301,15 @@ "workspace": { "attachment": "host_directory", "user_xattr": true + }, + "network": { + "public": true, + "domains": [] } }, "valid": true, - "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":false}}", - "digest": "cd31758a8ec9df729120eccd3db650d0f82cb35c4fbd1b1f5b7cf70140ae9faf" + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024},\"network\":{\"public\":true,\"domains\":[]},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":false}}", + "digest": "79d6a3f68ac1fcc4e4ad4f11edc2f9e3c7f03858c1a29392c2f8097950577b8a" }, { "name": "microsandbox-external-null-false-quota", @@ -264,10 +324,255 @@ "attachment": "host_directory", "user_xattr": true, "capacity_quota": null + }, + "network": { + "public": true, + "domains": [] } }, "valid": true, - "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":false}}", - "digest": "cd31758a8ec9df729120eccd3db650d0f82cb35c4fbd1b1f5b7cf70140ae9faf" + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024},\"network\":{\"public\":true,\"domains\":[]},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":false}}", + "digest": "79d6a3f68ac1fcc4e4ad4f11edc2f9e3c7f03858c1a29392c2f8097950577b8a" + }, + { + "name": "microsandbox-network-private-domains", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "workspace": { + "attachment": "host_directory", + "user_xattr": true, + "capacity_quota": true + }, + "network": { + "public": false, + "domains": [ + "gitlab.example.com", + "api.internal" + ] + } + }, + "valid": true, + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295},\"network\":{\"public\":false,\"domains\":[\"api.internal\",\"gitlab.example.com\"]},\"workspace\":{\"attachment\":\"host_directory\",\"user_xattr\":true,\"capacity_quota\":true}}", + "digest": "99e7ef956a54ca326b5a061376ce33f44de6832a80ce74747b1515bb1a926c1c" + }, + { + "name": "e2b-network-closed", + "provider": "e2b", + "specification": { + "resources": { + "cpus": 1, + "memory_mib": 512 + }, + "network": { + "public": false, + "domains": [] + } + }, + "valid": true, + "canonical": "{\"provider\":\"e2b\",\"resources\":{\"cpus\":1,\"memory_mib\":512},\"network\":{\"public\":false,\"domains\":[]}}", + "digest": "2cd439c0df3e648f23bd428c1c2a6bf13efb7c92959fae83ed460d71fa504e6b" + }, + { + "name": "e2b-network-private-domain-rejected", + "provider": "e2b", + "specification": { + "resources": { + "cpus": 1, + "memory_mib": 512 + }, + "network": { + "public": true, + "domains": [ + "gitlab.example.com" + ] + } + }, + "valid": false, + "canonical": "{\"provider\":\"e2b\",\"resources\":{\"cpus\":1,\"memory_mib\":512}}", + "digest": "bfedd8252478cfeb7e927ccd9aab6e812e0a35405046db3cf023fe62a869de04" + }, + { + "name": "network-wildcard-rejected", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "workspace": { + "attachment": "host_directory", + "user_xattr": true, + "capacity_quota": true + }, + "network": { + "public": true, + "domains": [ + "*.example.com" + ] + } + }, + "valid": false, + "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": "network-ip-literal-rejected", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "workspace": { + "attachment": "host_directory", + "user_xattr": true, + "capacity_quota": true + }, + "network": { + "public": true, + "domains": [ + "10.0.0.1" + ] + } + }, + "valid": false, + "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": "network-too-many-domains", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "workspace": { + "attachment": "host_directory", + "user_xattr": true, + "capacity_quota": true + }, + "network": { + "public": true, + "domains": [ + "h0.example.com", + "h1.example.com", + "h2.example.com", + "h3.example.com", + "h4.example.com", + "h5.example.com", + "h6.example.com", + "h7.example.com", + "h8.example.com", + "h9.example.com", + "h10.example.com", + "h11.example.com", + "h12.example.com", + "h13.example.com", + "h14.example.com", + "h15.example.com", + "h16.example.com", + "h17.example.com", + "h18.example.com", + "h19.example.com", + "h20.example.com", + "h21.example.com", + "h22.example.com", + "h23.example.com", + "h24.example.com", + "h25.example.com", + "h26.example.com", + "h27.example.com", + "h28.example.com", + "h29.example.com", + "h30.example.com", + "h31.example.com", + "h32.example.com", + "h33.example.com", + "h34.example.com", + "h35.example.com", + "h36.example.com", + "h37.example.com", + "h38.example.com", + "h39.example.com", + "h40.example.com", + "h41.example.com", + "h42.example.com", + "h43.example.com", + "h44.example.com", + "h45.example.com", + "h46.example.com", + "h47.example.com", + "h48.example.com", + "h49.example.com", + "h50.example.com", + "h51.example.com", + "h52.example.com", + "h53.example.com", + "h54.example.com", + "h55.example.com", + "h56.example.com", + "h57.example.com", + "h58.example.com", + "h59.example.com", + "h60.example.com", + "h61.example.com", + "h62.example.com", + "h63.example.com", + "h64.example.com", + "h65.example.com", + "h66.example.com", + "h67.example.com", + "h68.example.com", + "h69.example.com", + "h70.example.com", + "h71.example.com", + "h72.example.com", + "h73.example.com", + "h74.example.com", + "h75.example.com", + "h76.example.com", + "h77.example.com", + "h78.example.com", + "h79.example.com", + "h80.example.com", + "h81.example.com", + "h82.example.com", + "h83.example.com", + "h84.example.com", + "h85.example.com", + "h86.example.com", + "h87.example.com", + "h88.example.com", + "h89.example.com", + "h90.example.com", + "h91.example.com", + "h92.example.com", + "h93.example.com", + "h94.example.com", + "h95.example.com", + "h96.example.com", + "h97.example.com", + "h98.example.com", + "h99.example.com", + "h100.example.com" + ] + } + }, + "valid": false, + "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" } ] diff --git a/services/core/internal/sandbox/workspace_test.go b/services/core/internal/sandbox/workspace_test.go index d22cd3d06..36288c250 100644 --- a/services/core/internal/sandbox/workspace_test.go +++ b/services/core/internal/sandbox/workspace_test.go @@ -9,11 +9,11 @@ import ( 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} - spec := DeploymentSpec{Resources: Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 4096}, Workspace: declaration} + spec := DeploymentSpec{Resources: Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 4096}, Network: DefaultNetwork(), Workspace: declaration} if err := spec.ValidatePolicy("test", policy); err != nil { t.Fatal(err) } - if err := (DeploymentSpec{Resources: spec.Resources}).ValidatePolicy("test", policy); !errors.Is(err, ErrWorkspaceRequired) { + if err := (DeploymentSpec{Resources: spec.Resources, Network: spec.Network}).ValidatePolicy("test", policy); !errors.Is(err, ErrWorkspaceRequired) { t.Fatal("missing storage accepted", err) } if err := spec.ValidatePolicy("test", DeploymentPolicy{}); !errors.Is(err, workspacefs.ErrUnsupported) { diff --git a/services/core/migrations/000010_sandbox_network_cap.sql b/services/core/migrations/000010_sandbox_network_cap.sql new file mode 100644 index 000000000..9dea7835c --- /dev/null +++ b/services/core/migrations/000010_sandbox_network_cap.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- The deployment cap defaults to public access with no private domains. +UPDATE runtime_deployment SET specification = specification || '{"network":{"public":true,"domains":[]}}'::jsonb +WHERE specification <> '{}'::jsonb AND NOT specification ? 'network'; + +ALTER TABLE runtime_deployment_generations DISABLE TRIGGER immutable_sandbox_specification; +UPDATE runtime_deployment_generations SET specification = specification || '{"network":{"public":true,"domains":[]}}'::jsonb +WHERE specification <> '{}'::jsonb AND NOT specification ? 'network'; +ALTER TABLE runtime_deployment_generations ENABLE TRIGGER immutable_sandbox_specification; diff --git a/services/core/tests/integration/sandbox_deployment_view_test.go b/services/core/tests/integration/sandbox_deployment_view_test.go index 9f8051f31..84498ff6c 100644 --- a/services/core/tests/integration/sandbox_deployment_view_test.go +++ b/services/core/tests/integration/sandbox_deployment_view_test.go @@ -36,7 +36,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { } raw, _ := json.Marshal(view) for _, want := range []string{ - `"specification":{"resources":{"cpus":2,"memory_mib":2048}}`, + `"specification":{"resources":{"cpus":2,"memory_mib":2048},"network":{"public":true,"domains":[]}}`, `"template_build":{"status":"ready","resources":{"cpus":2,"memory_mib":2048,"root_disk_mib":24063}}`, `"suspension":{"idle_seconds":300,"retention_seconds":86400}`, } { diff --git a/services/core/tests/integration/sandbox_specification_test.go b/services/core/tests/integration/sandbox_specification_test.go index 3ba03726c..5a83fe84c 100644 --- a/services/core/tests/integration/sandbox_specification_test.go +++ b/services/core/tests/integration/sandbox_specification_test.go @@ -7,7 +7,7 @@ import ( ) func SandboxDeploymentTestSpec(provider string) sandbox.DeploymentSpec { - s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} + s := sandbox.DeploymentSpec{Network: sandbox.DefaultNetwork(), Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} if provider == "microsandbox" { s.Resources.RootDiskMiB = 8192 s.Workspace = &workspacefs.Declaration{Attachment: workspacefs.AttachmentHostDirectory, UserXAttr: true} diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index 050debbbe..a8752c01d 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -5,6 +5,7 @@ import math import time from datetime import datetime, timezone +from urllib.parse import urlsplit from uuid import UUID from e2b import Sandbox, SandboxQuery, SandboxState @@ -79,6 +80,22 @@ def observed(cloud, point): return {'Status': 'unavailable'} +# E2B sandboxes cannot reach private networks; the deny list closes the ranges +# a sandbox host could still route to. +BLOCKED_RANGES = ('127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', + '::1/128', 'fc00::/7', 'fe80::/10') +ALL_TRAFFIC = '0.0.0.0/0' + + +def network_options(bootstrap): + """The SDK network option for Core's egress: public access, or only Core and the model provider.""" + egress = bootstrap['Egress'] + if egress['public']: + return {'deny_out': list(BLOCKED_RANGES)} + allowed = [urlsplit(bootstrap['CoreURL']).hostname, *egress['endpoints']] + return {'deny_out': [ALL_TRAFFIC], 'allow_out': sorted(set(allowed))} + + class Provider: def __init__(self, request): if (not isinstance(request, dict) or @@ -234,7 +251,7 @@ def create(self): try: cloud = Sandbox.create(template=self.config['Template'], timeout=self.config['TimeoutSeconds'], metadata=self.metadata, lifecycle={'on_timeout': 'kill', 'auto_resume': False}, - **self.options()) + network=network_options(bootstrap), **self.options()) except Exception as error: if definitely_rejected(error): self.receipt.save(status='rejected', settled=True) @@ -259,7 +276,7 @@ def create(self): raise Failure('template_invalid' if check['ExitCode'] == 78 else 'unconfirmed') payload = dict(bootstrap, InstallationID=self.config['InstallationID'], RuntimeBootstrap=self.q['RuntimeBootstrap']) - del payload['CoreURL'], payload['Credential'], payload['SuspendControl'] + del payload['CoreURL'], payload['Credential'], payload['SuspendControl'], payload['Egress'] if (set(payload) - set(MANAGED_BOOTSTRAP_FIELDS) or not set(MANAGED_BOOTSTRAP_REQUIRED_FIELDS) <= set(payload)): raise Failure('invalid') diff --git a/services/core/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py index de49699d7..8bf98064e 100644 --- a/services/core/tools/e2b-provider/provider_test.py +++ b/services/core/tools/e2b-provider/provider_test.py @@ -32,7 +32,8 @@ def setUp(self): self.request = {'Version': PROTOCOL_VERSION, 'Operation': 'create', 'Config': self.config, 'Reference': self.reference, 'Deadline': (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), 'Bootstrap': dict(self.reference, SessionID=str(uuid4()), DeviceID=str(uuid4()), - CoreURL='https://core.example/api/v1', Credential='private-runtime-secret', SuspendControl=True)} + CoreURL='https://core.example/api/v1', Credential='private-runtime-secret', SuspendControl=True, + Egress={'public': True, 'private_domains': [], 'endpoints': []})} self.request['RuntimeBootstrap'] = { 'version': 3, 'suspend_control': True, 'core_url': self.request['Bootstrap']['CoreURL'], 'device_id': self.request['Bootstrap']['DeviceID'], @@ -89,6 +90,17 @@ def test_create_recover_and_never_replay(self): self.assertEqual(self.record()['connection']['envd_access_token'], 'private-envd-secret') self.api.connect.assert_not_called() + def test_create_maps_egress_to_network_options(self): + provider = __import__('provider') + public = provider.network_options(self.request['Bootstrap']) + self.assertEqual(public, {'deny_out': list(provider.BLOCKED_RANGES)}) + closed = dict(self.request['Bootstrap'], Egress={'public': False, 'private_domains': [], 'endpoints': ['api.model.example']}) + self.assertEqual(provider.network_options(closed), + {'deny_out': ['0.0.0.0/0'], 'allow_out': ['api.model.example', 'core.example']}) + self.assertEqual(self.call('create')['ErrorCode'], '') + self.assertNotIn('Egress', json.loads(self.cloud.files.write.call_args.args[1])) + self.assertEqual(self.api.create.call_args.kwargs['network'], public) + def test_create_response_without_metadata_reads_exact_id_before_bootstrap(self): created = SimpleNamespace(sandbox_id=self.cloud.sandbox_id, sandbox_domain=self.cloud.sandbox_domain, diff --git a/services/core/tools/e2b-provider/testdata/contract.json b/services/core/tools/e2b-provider/testdata/contract.json index 1fe242ec6..a95909968 100644 --- a/services/core/tools/e2b-provider/testdata/contract.json +++ b/services/core/tools/e2b-provider/testdata/contract.json @@ -12,42 +12,42 @@ {"kind":"managed","name":"valid","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":true}, {"kind":"managed","name":"workspace-\u003cnil\u003e","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"valid":true}, {"kind":"managed","name":"workspace-map[]","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"valid":false}, -{"kind":"request","name":"command","payload":{"Version":2,"Operation":"command","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"command_compute","payload":{"Version":2,"Operation":"command_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Command":{"Args":["true"],"Directory":"","Stdin":null},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"compute","payload":{"Version":2,"Operation":"compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"config-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":null,"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"create","payload":{"Version":2,"Operation":"create","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"delete_snapshot","payload":{"Version":2,"Operation":"delete_snapshot","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"extra","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Extra":true,"Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"inspect","payload":{"Version":2,"Operation":"inspect","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"kill","payload":{"Version":2,"Operation":"kill","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"kill_compute","payload":{"Version":2,"Operation":"kill_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"list_builds","payload":{"Version":2,"Operation":"list_builds","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"list_templates","payload":{"Version":2,"Operation":"list_templates","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"observe","payload":{"Version":2,"Operation":"observe","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"operation-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":null,"Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"operation-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"unsupported","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"reference-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":null,"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"reference-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"renew","payload":{"Version":2,"Operation":"renew","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"renew_compute","payload":{"Version":2,"Operation":"renew_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"resume","payload":{"Version":2,"Operation":"resume","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Resume":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Target":{"Generation":1,"Name":"fixture-g1","ID":"","RestoredFrom":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"}},"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"resume_compute","payload":{"Version":2,"Operation":"resume_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"suspend","payload":{"Version":2,"Operation":"suspend","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Suspend":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Source":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Snapshot":null,"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"validate_deployment","payload":{"Version":2,"Operation":"validate_deployment","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-0","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-32","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-33","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000021-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":{},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":[null],"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"version-bool","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":true},"valid":false}, -{"kind":"request","name":"version-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":null},"valid":false}, -{"kind":"request","name":"version-string","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":"1"},"valid":false}, -{"kind":"request","name":"version-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":3},"valid":false}, -{"kind":"request","name":"workspace-\u003cnil\u003e","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":true}, -{"kind":"request","name":"workspace-map[]","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"command","payload":{"Version":2,"Operation":"command","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"command_compute","payload":{"Version":2,"Operation":"command_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Command":{"Args":["true"],"Directory":"","Stdin":null},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"compute","payload":{"Version":2,"Operation":"compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"config-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":null,"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"create","payload":{"Version":2,"Operation":"create","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"delete_snapshot","payload":{"Version":2,"Operation":"delete_snapshot","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"extra","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Extra":true,"Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"inspect","payload":{"Version":2,"Operation":"inspect","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"kill","payload":{"Version":2,"Operation":"kill","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"kill_compute","payload":{"Version":2,"Operation":"kill_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"list_builds","payload":{"Version":2,"Operation":"list_builds","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"list_templates","payload":{"Version":2,"Operation":"list_templates","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"observe","payload":{"Version":2,"Operation":"observe","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"operation-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":null,"Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"operation-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"unsupported","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"reference-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":null,"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"reference-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"renew","payload":{"Version":2,"Operation":"renew","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"renew_compute","payload":{"Version":2,"Operation":"renew_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"resume","payload":{"Version":2,"Operation":"resume","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Resume":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Target":{"Generation":1,"Name":"fixture-g1","ID":"","RestoredFrom":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"}},"Egress":{"public":false,"private_domains":null,"endpoints":null},"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"resume_compute","payload":{"Version":2,"Operation":"resume_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"suspend","payload":{"Version":2,"Operation":"suspend","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Suspend":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Source":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Snapshot":null,"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"validate_deployment","payload":{"Version":2,"Operation":"validate_deployment","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-0","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-32","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-33","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000021-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false,"Egress":{"public":false,"private_domains":null,"endpoints":null}},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":{},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":[null],"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"version-bool","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":true},"valid":false}, +{"kind":"request","name":"version-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":null},"valid":false}, +{"kind":"request","name":"version-string","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":"1"},"valid":false}, +{"kind":"request","name":"version-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":3},"valid":false}, +{"kind":"request","name":"workspace-\u003cnil\u003e","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":true}, +{"kind":"request","name":"workspace-map[]","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","Egress":{"endpoints":null,"private_domains":null,"public":false},"EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, {"kind":"response","name":"error-","payload":{"Version":2,"ErrorCode":""},"valid":true}, {"kind":"response","name":"error-checkpoint_unusable","payload":{"Version":2,"ErrorCode":"checkpoint_unusable"},"valid":true}, {"kind":"response","name":"error-command_unconfirmed","payload":{"Version":2,"ErrorCode":"command_unconfirmed"},"valid":true}, diff --git a/services/core/tools/microsandbox-provider/backend.go b/services/core/tools/microsandbox-provider/backend.go index 3da947ba6..a13dcfb92 100644 --- a/services/core/tools/microsandbox-provider/backend.go +++ b/services/core/tools/microsandbox-provider/backend.go @@ -5,6 +5,7 @@ package main import ( "context" "errors" + "os" "path/filepath" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -113,13 +114,10 @@ func (b backend) kill(ctx context.Context, c wire.Compute) error { return e } -func (b backend) network() *sdk.NetworkConfig { - n := b.q.Config.Network - result := &sdk.NetworkConfig{DefaultEgress: sdk.PolicyAction(n.DefaultEgress), DefaultIngress: sdk.PolicyAction(n.DefaultIngress)} - for _, r := range n.Rules { - result.Rules = append(result.Rules, sdk.PolicyRule{Action: sdk.PolicyAction(r.Action), Direction: sdk.PolicyDirection(r.Direction), Destination: r.Destination, Protocol: sdk.PolicyProtocol(r.Protocol), Port: r.Port}) - } - return result +// network builds the allocation's network policy from its Egress with the +// node host's DNS. Names that do not resolve are reported to the node's log. +func (b backend) network(ctx context.Context, egress sandbox.Egress) (*sdk.NetworkConfig, error) { + return egressNetwork(ctx, b.q.Config.CoreURL, egress, hostResolver, os.Stderr) } func (b backend) resumeCompute(ctx context.Context, c wire.Compute) (wire.State, error) { diff --git a/services/core/tools/microsandbox-provider/bootstrap.go b/services/core/tools/microsandbox-provider/bootstrap.go index 21c294124..f3645a1d6 100644 --- a/services/core/tools/microsandbox-provider/bootstrap.go +++ b/services/core/tools/microsandbox-provider/bootstrap.go @@ -52,6 +52,10 @@ func (b backend) create(ctx context.Context) (wire.Response, error) { for key, value := range workspaceLabels(b.q.Workspace) { labels[key] = value } + network, e := b.network(ctx, bootstrap.Egress) + if e != nil { + return wire.Response{}, e + } 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 +66,7 @@ func (b backend) create(ctx context.Context) (wire.Response, error) { sdk.WithWorkdir("/"), sdk.WithMounts(map[string]sdk.MountConfig{ "/environment": sdk.Mount.Bind(b.q.Workspace.Path, sdk.MountOptions{}), }), - sdk.WithLabels(labels), sdk.WithDetached(), sdk.WithQuietLogs(), sdk.WithNetwork(b.network()), + sdk.WithLabels(labels), sdk.WithDetached(), sdk.WithQuietLogs(), sdk.WithNetwork(network), sdk.WithEnv(map[string]string{ "HOME": "/home/runtime", "OAC_RUNTIME_HOME": "/home/runtime/.oac", })) diff --git a/services/core/tools/microsandbox-provider/lock_test.go b/services/core/tools/microsandbox-provider/lock_test.go index 02e0ab5eb..db8899188 100644 --- a/services/core/tools/microsandbox-provider/lock_test.go +++ b/services/core/tools/microsandbox-provider/lock_test.go @@ -58,7 +58,7 @@ func initialInfoRequest(t *testing.T) wire.Request { config.RuntimeHome = t.TempDir() config.CheckpointRoot = checkpointFixture(t) config.RuntimeSHA256, config.FirmwareSHA256 = strings.Repeat("a", 64), strings.Repeat("b", 64) - config.Network = wire.NetworkPolicy{DefaultEgress: "deny", DefaultIngress: "deny"} + config.CoreURL = "https://core.example.com" ref := sandbox.Reference{TenantID: "22222222-2222-4222-8222-222222222222", EnvironmentID: "33333333-3333-4333-8333-333333333333", AllocationID: "44444444-4444-4444-8444-444444444444"} return wire.Request{Version: wire.ProtocolVersion, Operation: "initial_info", Config: config, Reference: ref, Compute: wire.Compute{Name: wire.Name(config, ref, 0)}, Deadline: time.Now().Add(5 * time.Second)} diff --git a/services/core/tools/microsandbox-provider/network.go b/services/core/tools/microsandbox-provider/network.go new file mode 100644 index 000000000..6978d65d3 --- /dev/null +++ b/services/core/tools/microsandbox-provider/network.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/netip" + "net/url" + "slices" + "strconv" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + sdk "github.com/superradcompany/microsandbox/sdk/go" +) + +// resolver looks a host name up with the node host's DNS. +type resolver func(ctx context.Context, host string) ([]netip.Addr, error) + +func hostResolver(ctx context.Context, host string) ([]netip.Addr, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return net.DefaultResolver.LookupNetIP(ctx, "ip", host) +} + +var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10") + +func isPrivate(a netip.Addr) bool { return a.IsPrivate() || carrierGradeNAT.Contains(a) } + +// egressNetwork builds one allocation's network policy; Create and Resume call +// it with the same Egress. Rules are first match with default deny: +// 1. host DNS +// 2. deny loopback, link-local and metadata +// 3. each address of the Core host, on its port +// 4. each private address that an Endpoint or PrivateDomains name resolves to, +// without port, because only a port-less address allow lets the guest's DNS +// answer pass rebind protection, which stays at its default +// 5. deny every other private address +// 6. unless Public, a domain allow for each Endpoint and PrivateDomains name, +// so their public answers work +// 7. public addresses, when Public +// +// A name that does not resolve gets no address rule and is reported to report. +func egressNetwork(ctx context.Context, coreURL string, egress sandbox.Egress, resolve resolver, report io.Writer) (*sdk.NetworkConfig, error) { + core, err := url.Parse(coreURL) + if err != nil || core.Hostname() == "" { + return nil, sandbox.ErrInvalid + } + port := core.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443"}[core.Scheme] + } + if _, err := strconv.ParseUint(port, 10, 16); err != nil { + return nil, sandbox.ErrInvalid + } + coreAddresses, err := addresses(ctx, core.Hostname(), resolve) + if err != nil { + return nil, err + } + names := slices.Concat(egress.Endpoints, egress.PrivateDomains) + slices.Sort(names) + names = slices.Compact(names) + + var private []string + for _, name := range names { + found, err := addresses(ctx, name, resolve) + if err != nil { + fmt.Fprintf(report, "unresolved %s\n", name) + continue + } + for _, a := range found { + if isPrivate(a) { + private = append(private, a.String()) + } + } + } + slices.Sort(private) + private = slices.Compact(private) + + n := &sdk.NetworkConfig{DefaultEgress: sdk.PolicyActionDeny, DefaultIngress: sdk.PolicyActionDeny} + allow := func(destination, protocol, port string) { + n.Rules = append(n.Rules, sdk.PolicyRule{Action: sdk.PolicyActionAllow, Direction: sdk.PolicyDirectionEgress, Destination: destination, Protocol: sdk.PolicyProtocol(protocol), Port: port}) + } + deny := func(destination string) { + n.Rules = append(n.Rules, sdk.PolicyRule{Action: sdk.PolicyActionDeny, Direction: sdk.PolicyDirectionEgress, Destination: destination}) + } + allow("host", "udp", "53") + allow("host", "tcp", "53") + for _, group := range []string{"loopback", "link-local", "metadata"} { + deny(group) + } + for _, a := range coreAddresses { + allow(a.String(), "tcp", port) + } + for _, a := range private { + allow(a, "", "") + } + deny("private") + if !egress.Public { + for _, name := range names { + if a, err := netip.ParseAddr(name); err == nil { + allow(a.Unmap().String(), "", "") + } else if sandbox.ValidDomain(name) { + allow(name, "", "") + } else { + fmt.Fprintf(report, "unsupported %s\n", name) + } + } + } + if egress.Public { + allow("public", "", "") + } + return n, nil +} + +// addresses returns the sorted, unmapped addresses of a host name or literal. +func addresses(ctx context.Context, host string, resolve resolver) ([]netip.Addr, error) { + if a, err := netip.ParseAddr(host); err == nil { + return []netip.Addr{a.Unmap()}, nil + } + found, err := resolve(ctx, host) + if err != nil || len(found) == 0 { + return nil, fmt.Errorf("host did not resolve") + } + result := make([]netip.Addr, 0, len(found)) + for _, a := range found { + result = append(result, a.Unmap().WithZone("")) + } + slices.SortFunc(result, netip.Addr.Compare) + return slices.Compact(result), nil +} diff --git a/services/core/tools/microsandbox-provider/network_test.go b/services/core/tools/microsandbox-provider/network_test.go new file mode 100644 index 000000000..1ef1e19f6 --- /dev/null +++ b/services/core/tools/microsandbox-provider/network_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/netip" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func fixedDNS(table map[string][]string) resolver { + return func(_ context.Context, host string) ([]netip.Addr, error) { + answers, ok := table[host] + if !ok { + return nil, errors.New("no such host") + } + var out []netip.Addr + for _, a := range answers { + out = append(out, netip.MustParseAddr(a)) + } + return out, nil + } +} + +func rules(t *testing.T, egress sandbox.Egress, dns map[string][]string) (string, string) { + t.Helper() + var report bytes.Buffer + n, err := egressNetwork(context.Background(), "https://core.example.com:24443", egress, fixedDNS(dns), &report) + if err != nil { + t.Fatal(err) + } + if n.DefaultEgress != "deny" || n.DefaultIngress != "deny" || n.DNS != nil { + t.Fatalf("defaults or DNS options changed: %+v", n) + } + var lines []string + for _, r := range n.Rules { + lines = append(lines, fmt.Sprintf("%s %s %s %s", r.Action, r.Destination, r.Protocol, r.Port)) + } + return strings.Join(lines, "\n"), report.String() +} + +func TestDefaultPolicyRules(t *testing.T) { + got, _ := rules(t, sandbox.Egress{Public: true, Endpoints: []string{"api.openai.com"}}, map[string][]string{ + "core.example.com": {"172.29.144.1"}, "api.openai.com": {"104.18.1.1"}}) + want := `allow host udp 53 +allow host tcp 53 +deny loopback +deny link-local +deny metadata +allow 172.29.144.1 tcp 24443 +deny private +allow public ` + if got != want { + t.Fatalf("rules:\n%s", got) + } +} + +func TestPrivateDomainRules(t *testing.T) { + got, report := rules(t, sandbox.Egress{PrivateDomains: []string{"gitlab.example.com", "gone.example.com"}, Endpoints: []string{"models.example.com"}}, map[string][]string{ + "core.example.com": {"172.29.144.1"}, "gitlab.example.com": {"10.74.2.81", "8.8.8.8"}, "models.example.com": {"203.0.113.9"}}) + want := `allow host udp 53 +allow host tcp 53 +deny loopback +deny link-local +deny metadata +allow 172.29.144.1 tcp 24443 +allow 10.74.2.81 +deny private +allow gitlab.example.com +allow gone.example.com +allow models.example.com ` + if got != want { + t.Fatalf("rules:\n%s", got) + } + if report != "unresolved gone.example.com\n" { + t.Fatalf("report %q", report) + } +} diff --git a/services/core/tools/microsandbox-provider/snapshot.go b/services/core/tools/microsandbox-provider/snapshot.go index 155dc841f..889a502c1 100644 --- a/services/core/tools/microsandbox-provider/snapshot.go +++ b/services/core/tools/microsandbox-provider/snapshot.go @@ -341,7 +341,11 @@ 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, + network, e := b.network(ctx, q.Egress) + if e != nil { + return wire.State{}, b.closeUndispatchedRestore(q, e) + } + restore := sdk.RestoreConfig{NetworkPolicy: 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