() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx
index cb869e3d4..1aaa40ae0 100644
--- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx
+++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx
@@ -16,7 +16,7 @@ vi.mock("./NodeEnrollment", () => ({ NodeEnrollment: () => node-enrollment<
function cache(provider: SandboxDeployment["provider"]) {
const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } });
- const deployment: SandboxDeployment = {
+ const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {},
installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example",
mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null,
rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null },
diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts
index 5268392cf..80dbfba48 100644
--- a/apps/web/src/features/sandbox/sandbox-queries.test.ts
+++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts
@@ -8,7 +8,7 @@ const reset: SandboxReset = {
clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null,
remaining: { busy: 1, idle: 0, cleanup: 1, on_offline_nodes: 1, offline_nodes: [{ node_id: "node-a", name: "Offline host", resources: 1 }] },
};
-const deployment = (overrides: Partial = {}): SandboxDeployment => ({
+const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {},
rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "installation-a", provider: "docker", core_url: "https://core.example", reset,
owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, pending: 0 }, suspension: null, ...overrides,
});
diff --git a/apps/web/src/features/sandbox/sandbox-update.test.ts b/apps/web/src/features/sandbox/sandbox-update.test.ts
index da2644f9b..9c1a10e9d 100644
--- a/apps/web/src/features/sandbox/sandbox-update.test.ts
+++ b/apps/web/src/features/sandbox/sandbox-update.test.ts
@@ -3,13 +3,13 @@ import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update";
describe("E2B update selection", () => {
it("omits a retained key while keeping the complete template selection", () => {
- expect(e2bUpdateSelection(" template:build ", " ")).toEqual({ template: "template:build" });
+ expect(e2bUpdateSelection(" template:build ", " ")).toEqual({ configuration: { template: "template:build" } });
expect(e2bKeyReady(true, false, "")).toBe(true);
expect(e2bKeyReady(false, false, "")).toBe(false);
});
it("always sends an explicit replacement, including repeated identical values", () => {
- for (let attempt = 0; attempt < 2; attempt++) expect(e2bUpdateSelection("template:build", " test-key ")).toEqual({ template: "template:build", api_key: "test-key" });
+ for (let attempt = 0; attempt < 2; attempt++) expect(e2bUpdateSelection("template:build", " test-key ")).toEqual({ configuration: { template: "template:build" }, credential: { api_key: "test-key" } });
});
it("does not silently turn a cleared replacement secret into retained-key resubmission", () => {
diff --git a/apps/web/src/features/sandbox/sandbox-update.ts b/apps/web/src/features/sandbox/sandbox-update.ts
index 3eb4a5a2c..7673a5753 100644
--- a/apps/web/src/features/sandbox/sandbox-update.ts
+++ b/apps/web/src/features/sandbox/sandbox-update.ts
@@ -1,9 +1,9 @@
import type { UpdateSandboxDeployment } from "@agents-core-web/agents-client";
/** Omission preserves the credential; every explicit key follows Core's replacement path. */
-export function e2bUpdateSelection(template: string, apiKey: string): NonNullable {
+export function e2bUpdateSelection(template: string, apiKey: string): Pick {
const key = apiKey.trim();
- return { template: template.trim(), ...(key ? { api_key: key } : {}) };
+ return { configuration: { template: template.trim() }, ...(key ? { credential: { api_key: key } } : {}) };
}
/** Clearing a submitted secret must not silently convert replacement into retention. */
diff --git a/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx b/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx
index 77e416818..fe9ed1b47 100644
--- a/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx
+++ b/apps/web/src/features/sandbox/use-sandbox-manager-state.test.tsx
@@ -6,7 +6,7 @@ import { node } from "../overview/test-fixtures";
import { sandboxAdmin, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries";
import { useSandboxManagerState } from "./use-sandbox-manager-state";
-const configured: SandboxDeployment = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null };
+const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null };
const resetting: SandboxDeployment = { ...configured, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } };
const clients: QueryClient[] = [];
function cache() {
diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts
index 8b2c7e240..2aabbc23a 100644
--- a/apps/web/src/i18n/locales/en/core-errors.ts
+++ b/apps/web/src/i18n/locales/en/core-errors.ts
@@ -37,9 +37,9 @@ export const coreErrors = {
"runtime_node_in_use": "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.",
"runtime_node_unavailable": "The selected sandbox node is unavailable or has no capacity.",
"sandbox_admin_not_configured": "Sandbox administration is not configured on this console.",
- "e2b_team_mismatch": "This E2B key cannot manage the retained deployment. Reset before changing teams.",
- "e2b_api_key_invalid": "The E2B API key was rejected. The saved configuration is unchanged.",
- "e2b_template_build_invalid": "Select a ready immutable E2B template build with matching resources.",
- "e2b_request_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.",
+ "sandbox_credential_ownership": "This E2B key cannot manage the retained deployment. Reset before changing teams.",
+ "sandbox_credential_invalid": "The E2B API key was rejected. The saved configuration is unchanged.",
+ "sandbox_configuration_invalid": "Select a ready immutable E2B template build with matching resources.",
+ "sandbox_verification_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.",
"sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Configure a domain and HTTPS in System."
} as const;
diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts
index 4ac6298ce..64db7d096 100644
--- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts
+++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts
@@ -37,9 +37,9 @@ export const coreErrors = {
"runtime_node_in_use": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。",
"runtime_node_unavailable": "所选沙箱节点不可用或容量不足。",
"sandbox_admin_not_configured": "此控制台尚未配置沙箱管理权限。",
- "e2b_team_mismatch": "此 E2B 密钥无法管理当前保留的部署。更换团队前请先重置。",
- "e2b_api_key_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。",
- "e2b_template_build_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。",
- "e2b_request_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。",
+ "sandbox_credential_ownership": "此 E2B 密钥无法管理当前保留的部署。更换团队前请先重置。",
+ "sandbox_credential_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。",
+ "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。",
+ "sandbox_verification_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。",
"sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请在系统中配置域名与 HTTPS。"
} as const;
diff --git a/apps/web/src/lib/core-error.test.ts b/apps/web/src/lib/core-error.test.ts
index 19b6d765d..b70e28e1d 100644
--- a/apps/web/src/lib/core-error.test.ts
+++ b/apps/web/src/lib/core-error.test.ts
@@ -13,7 +13,7 @@ const failure = (code: string, param?: string, details?: AgentCoreError["details
describe("Core error catalog localization", () => {
it("covers both languages, without relying on backend prose", () => {
expect(Object.keys(chinese).sort()).toEqual(Object.keys(english).sort());
- for (const code of ["invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable", "invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_provider_base_url_invalid", "model_provider_protocol_unsupported", "model_provider_api_key_invalid", "model_provider_token_limits_invalid", "invalid_sandbox_configuration", "e2b_api_key_invalid", "e2b_template_build_invalid", "e2b_team_mismatch", "e2b_request_unconfirmed", "sandbox_generation_stale", "sandbox_admin_not_configured", "project_archived", "project_exists", "project_api_key_exists"]) {
+ for (const code of ["invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable", "invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_provider_base_url_invalid", "model_provider_protocol_unsupported", "model_provider_api_key_invalid", "model_provider_token_limits_invalid", "invalid_sandbox_configuration", "sandbox_credential_invalid", "sandbox_configuration_invalid", "sandbox_credential_ownership", "sandbox_verification_unconfirmed", "sandbox_generation_stale", "sandbox_admin_not_configured", "project_archived", "project_exists", "project_api_key_exists"]) {
expect(knownCoreError(failure(code), en)).not.toBeNull();
expect(coreError(failure(code), en)).not.toContain("backend prose");
expect(coreError(failure(code), zh)).toMatch(/[\u4e00-\u9fff]/);
diff --git a/apps/web/src/lib/core-error.ts b/apps/web/src/lib/core-error.ts
index 256090178..96d51a257 100644
--- a/apps/web/src/lib/core-error.ts
+++ b/apps/web/src/lib/core-error.ts
@@ -3,7 +3,7 @@ import type { TFunction } from "i18next";
import type { coreErrors } from "../i18n/locales/en/core-errors";
const codes = new Set([
- "e2b_team_mismatch", "e2b_api_key_invalid", "e2b_template_build_invalid", "e2b_request_unconfirmed", "sandbox_configuration_error",
+ "sandbox_credential_ownership", "sandbox_credential_invalid", "sandbox_configuration_invalid", "sandbox_verification_unconfirmed", "sandbox_configuration_error",
"sandbox_generation_stale", "sandbox_reset_required", "sandbox_reset_in_progress", "sandbox_not_configured", "sandbox_in_use", "runtime_node_in_use", "runtime_node_unavailable", "sandbox_admin_not_configured",
"invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable",
"invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_configuration_model_invalid", "harness_config_invalid", "model_provider_base_url_invalid",
diff --git a/apps/web/src/lib/sandbox-labels.test.ts b/apps/web/src/lib/sandbox-labels.test.ts
index 7405b0ed1..f797c37ad 100644
--- a/apps/web/src/lib/sandbox-labels.test.ts
+++ b/apps/web/src/lib/sandbox-labels.test.ts
@@ -5,7 +5,7 @@ import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncerta
describe("sandbox write outcome", () => {
it("uses fixed bilingual E2B errors without reflecting provider text or secrets", () => {
- for (const [code, status] of [["e2b_team_mismatch", 409], ["e2b_api_key_invalid", 400], ["e2b_template_build_invalid", 400], ["e2b_request_unconfirmed", 503]] as const) {
+ for (const [code, status] of [["sandbox_credential_ownership", 409], ["sandbox_credential_invalid", 400], ["sandbox_configuration_invalid", 400], ["sandbox_verification_unconfirmed", 503]] as const) {
const error = new AgentCoreError("secret-provider-response", status, code);
for (const locale of ["en", "zh"] as const) {
expect(sandboxRequestError(error, locale)).not.toContain("secret-provider-response");
diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md
index 774123304..ee8067df8 100644
--- a/contracts/agents-api/core-errors.md
+++ b/contracts/agents-api/core-errors.md
@@ -66,10 +66,10 @@ message, template name, key or unlisted-resource count is returned in details.
| HTTP | Code | Meaning | Param |
| --- | --- | --- | --- |
-| 400 | `e2b_api_key_invalid` | Provider explicitly rejected authentication | `e2b.api_key` |
-| 400 | `e2b_template_build_invalid` | Candidate immutable build is invalid or does not match resources | `e2b.template` |
-| 409 | `e2b_team_mismatch` | Candidate key does not prove ownership/manageability of the retained deployment | `e2b.api_key` |
-| 503 | `e2b_request_unconfirmed` | Verification, receipt settlement or bounded credential fencing could not be confirmed | null |
+| 400 | `sandbox_credential_invalid` | Provider explicitly rejected authentication | `credential` |
+| 400 | `sandbox_configuration_invalid` | Candidate immutable build is invalid or does not match resources | `configuration` |
+| 409 | `sandbox_credential_ownership` | Candidate key does not prove ownership/manageability of the retained deployment | `credential` |
+| 503 | `sandbox_verification_unconfirmed` | Verification, receipt settlement or bounded credential fencing could not be confirmed | null |
Missing or unsettled Create receipts are uncertainty, never evidence of a different
team or released compute. The typed client projects these codes to fixed local
diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml
index decee3473..0f25c0188 100644
--- a/contracts/agents-api/core.openapi.yaml
+++ b/contracts/agents-api/core.openapi.yaml
@@ -512,8 +512,10 @@ definitions:
type: object
api.SandboxDeploymentChangeInput:
properties:
- e2b:
- $ref: '#/definitions/api.SandboxE2BInput'
+ configuration:
+ type: object
+ credential:
+ type: object
expected_generation:
type: integer
provider:
@@ -531,8 +533,10 @@ definitions:
type: object
api.SandboxDeploymentInput:
properties:
- e2b:
- $ref: '#/definitions/api.SandboxE2BInput'
+ configuration:
+ type: object
+ credential:
+ type: object
expected_generation:
type: integer
provider:
@@ -548,37 +552,6 @@ definitions:
required:
- expected_generation
type: object
- api.SandboxE2BDiscoveryInput:
- properties:
- api_key:
- type: string
- api_url:
- type: string
- domain:
- type: string
- type: object
- api.SandboxE2BDiscoveryResult:
- properties:
- builds:
- items:
- $ref: '#/definitions/e2b.ReadyBuild'
- type: array
- templates:
- items:
- $ref: '#/definitions/e2b.TemplateSummary'
- type: array
- type: object
- api.SandboxE2BInput:
- properties:
- api_key:
- type: string
- api_url:
- type: string
- domain:
- type: string
- template:
- type: string
- type: object
api.SandboxEnrollmentToken:
properties:
enrollment_id:
@@ -890,23 +863,14 @@ definitions:
service:
$ref: '#/definitions/coremetrics.ServiceState'
type: object
- e2b.ReadyBuild:
- properties:
- cpus:
- type: integer
- id:
- type: string
- memory_mib:
- type: integer
- type: object
- e2b.TemplateSummary:
+ sandbox.ConfigurationDiscoveryInput:
properties:
- id:
- type: string
- names:
- items:
- type: string
- type: array
+ configuration:
+ type: object
+ credential:
+ type: object
+ query:
+ type: object
type: object
sandbox.DeploymentSpec:
properties:
@@ -1147,15 +1111,19 @@ definitions:
type: object
store.RuntimeDeploymentView:
properties:
+ configuration:
+ type: object
core_url:
description: 'Read-only: the installation public URL (OAC_PUBLIC_URL), which nodes and sandboxes use to reach Core. The deployment API does not accept it.'
type: string
- e2b:
- $ref: '#/definitions/store.SandboxE2BView'
+ credential_configured:
+ type: boolean
generation:
type: integer
installation_id:
type: string
+ metadata:
+ type: object
mode:
type: string
owner_epoch:
@@ -1375,30 +1343,6 @@ definitions:
pending:
type: integer
type: object
- store.SandboxE2BTemplateBuildView:
- properties:
- resources:
- $ref: '#/definitions/store.SandboxTemplateResources'
- status:
- description: Build status at selection time; validation admits only ready builds.
- type: string
- x-nullable: true
- type: object
- store.SandboxE2BView:
- properties:
- api_url:
- type: string
- credential_configured:
- type: boolean
- domain:
- type: string
- template:
- type: string
- template_build:
- allOf:
- - $ref: '#/definitions/store.SandboxE2BTemplateBuildView'
- description: The fixed template build as Core read it when this selection was saved.
- type: object
store.SandboxNodeRollout:
properties:
diagnostic:
@@ -1517,18 +1461,6 @@ definitions:
retention_seconds:
type: integer
type: object
- store.SandboxTemplateResources:
- properties:
- cpus:
- type: integer
- x-nullable: true
- memory_mib:
- type: integer
- x-nullable: true
- root_disk_mib:
- type: integer
- x-nullable: true
- type: object
store.WriteOperation:
properties:
action:
@@ -6427,83 +6359,6 @@ paths:
summary: Start or escalate a durable sandbox deployment reset
tags:
- Sandbox Manager
- /core/v1/sandbox/e2b/templates:
- post:
- consumes:
- - application/json
- description: Core key only. Uses a transient E2B credential and endpoint through the pinned SDK helper; returns safe template metadata. Does not save the credential or allocate compute.
- parameters:
- - description: Transient E2B connection
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/api.SandboxE2BDiscoveryInput'
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.SandboxE2BDiscoveryResult'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.CoreErrorResponse'
- "401":
- description: Unauthorized
- schema:
- $ref: '#/definitions/api.CoreErrorResponse'
- "503":
- description: Service Unavailable
- schema:
- $ref: '#/definitions/api.CoreErrorResponse'
- security:
- - DeploymentAdminAuth: []
- summary: List templates visible to an E2B credential
- tags:
- - Sandbox Manager
- /core/v1/sandbox/e2b/templates/{template_id}/builds:
- post:
- consumes:
- - application/json
- description: Core key only. Reads one template through the pinned SDK helper with a transient E2B credential. Returns ready builds only, without allocating compute.
- parameters:
- - description: Template ID
- in: path
- name: template_id
- required: true
- type: string
- - description: Transient E2B connection
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/api.SandboxE2BDiscoveryInput'
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.SandboxE2BDiscoveryResult'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.CoreErrorResponse'
- "401":
- description: Unauthorized
- schema:
- $ref: '#/definitions/api.CoreErrorResponse'
- "503":
- description: Service Unavailable
- schema:
- $ref: '#/definitions/api.CoreErrorResponse'
- security:
- - DeploymentAdminAuth: []
- summary: List ready builds for an E2B template
- tags:
- - Sandbox Manager
/core/v1/sandbox/enrollment-tokens:
post:
consumes:
@@ -6781,6 +6636,52 @@ paths:
summary: List retained allocations on a sandbox node
tags:
- Sandbox Manager
+ /core/v1/sandbox/providers/{provider}/discovery:
+ post:
+ consumes:
+ - application/json
+ description: Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission.
+ parameters:
+ - description: Registered provider kind
+ in: path
+ name: provider
+ required: true
+ type: string
+ - description: Transient provider connection and query
+ in: body
+ name: body
+ required: true
+ schema:
+ $ref: '#/definitions/sandbox.ConfigurationDiscoveryInput'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ additionalProperties: true
+ type: object
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ "503":
+ description: Service Unavailable
+ schema:
+ $ref: '#/definitions/api.CoreErrorResponse'
+ security:
+ - DeploymentAdminAuth: []
+ summary: Discover sandbox provider configuration
+ tags:
+ - Sandbox Manager
/core/v1/sandbox/runtime-observations:
get:
description: Core key only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request.
diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md
index 7bef7f43f..0a56a251d 100644
--- a/contracts/agents-api/sandbox-deployment.md
+++ b/contracts/agents-api/sandbox-deployment.md
@@ -24,11 +24,14 @@ for the operator workflow. Generated schemas cover the
| `PUT /core/v1/sandbox/deployment` | Core key | Advance the same-provider target online while retaining existing ownership |
| `POST /core/v1/sandbox/deployment/reset` | Core key | Start or escalate a durable hosted clear |
| `DELETE /core/v1/sandbox/deployment/reset?expected_generation=N` | Core key | Cancel the remaining clear without restoring archived work |
-| `POST /core/v1/sandbox/e2b/templates` | Core Web server or operator script with Core key | List up to 200 templates visible to a transient E2B credential |
-| `POST /core/v1/sandbox/e2b/templates/{template_id}/builds` | Core Web server or operator script with Core key | List up to 200 ready builds for one selected template |
+| `POST /core/v1/sandbox/providers/{provider}/discovery` | Core Web server or operator script with Core key | Query the registered provider's configuration catalog without saving credentials or allocating compute |
| `GET /api/v1/sandbox-node/configuration` | Enrollment token or retained node credential | Read the active node installation configuration without consuming enrollment |
-Template discovery posts `{ "api_key": "...", "api_url": "https://sandbox.sandbase.ai", "domain": "sandbox.sandbase.ai" }`.
+E2B discovery posts `{ "configuration": { "api_url": "https://sandbox.sandbase.ai", "domain": "sandbox.sandbase.ai" }, "credential": { "api_key": "..." }, "query": {} }`
+to `/core/v1/sandbox/providers/e2b/discovery`. Use `query: {"template":"template-id"}` for builds.
+Requests are limited to 64 KiB and 30 seconds. Docker and microsandbox explicitly
+reject discovery with `400 sandbox_operation_unsupported`. Unknown input members
+and null objects reject. A discovery result never proves deployment admission.
The official E2B endpoint may omit both endpoint fields. The first response is
`{ "templates": [{ "id": "...", "names": ["..."] }] }`;
the second is `{ "builds": [{ "id": "build-uuid", "cpus": 2, "memory_mib": 2048 }] }`.
@@ -103,7 +106,8 @@ resource and same-selection conditions, including an identical old request body.
| `provider` | Exactly one of `docker`, `microsandbox`, `e2b` |
| `resources` | Per-sandbox resource limits described below; required for Docker/microsandbox, optional for E2B |
| `runtime` | Required immutable distribution identity for Docker/microsandbox; absent for E2B |
-| `e2b` | Required only for E2B: immutable `template` build selector; write-only `api_key` required on POST, optional on same-provider PUT; optional paired `api_url` and `domain` selectors |
+| `configuration` | Provider-owned public selectors. E2B accepts immutable `template` and optional paired `api_url`/`domain`; node providers accept only `{}` or omission. |
+| `credential` | Write-only provider credential object. E2B accepts `{api_key}`; required at first setup, omitted on PUT to preserve the key. Null and empty keys reject. Node providers reject this object. |
The request has no Core address. Core derives the deployment's `core_url` from the
installation public URL (`public_url` in `config.json`, `OAC_PUBLIC_URL` for
@@ -174,37 +178,48 @@ IDs and OCI manifest digests identify different objects; do not substitute one
for the other. The node installer verifies the saved release against its payload
before registration and retains the exact local image identity it imports.
-E2B instead uses `e2b.template` in `template-id:build-uuid` form. The build UUID must
+E2B instead uses `configuration.template` in `template-id:build-uuid` form. The build UUID must
be canonical and nonzero; a mutable template alias alone is insufficient. Omit
`runtime`. The API key is encrypted in PostgreSQL and never returned in a safe
view, bootstrap configuration, command argument or log. Same-team key, build or endpoint
changes apply online while old sandboxes retain their original specification.
By default Core uses `https://api.e2b.app` and `e2b.app`. For a compatible
-service, set both `e2b.api_url` (HTTPS API origin, with no path, port, query,
-fragment or credentials) and `e2b.domain` (sandbox data-plane DNS suffix).
+service, set both `configuration.api_url` (HTTPS API origin, with no path, port, query,
+fragment or credentials) and `configuration.domain` (sandbox data-plane DNS suffix).
The API host must equal the data-plane domain or be its subdomain. Core rejects
a sandbox response whose data-plane domain lies outside the selected suffix
before sending daemon credentials or using envd. Existing sandboxes retain
their original endpoint and credential across online changes.
+The operator contract uses these fields directly; the retired `e2b` request/response
+member and vendor-specific discovery routes have no fallback. Migration 91 moves
+existing selectors and observations into the generic objects without changing
+ciphertext, generation or immutable retained ownership. Downgrade refuses unknown
+provider configurations that the old schema cannot represent. The pinned `/v1`
+Agents API is unchanged.
+
## Safe response
GET and successful mutations return `installation_id`, `provider`, `core_url`
(read-only: the installation public URL, present before configuration), `mode`,
`generation`, `owner_epoch`, `reset`, `rollout`, `suspension` and resource
accounting. A configured deployment also returns `specification` and
-`specification_digest`. E2B returns `e2b.template`, `e2b.api_url`,
-`e2b.domain`, `e2b.credential_configured` and `e2b.template_build`; the `e2b` object is absent
-for Docker and microsandbox.
-
-`e2b.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`:
+`specification_digest`, `configuration` and `metadata`. Every response includes
+`credential_configured`. E2B returns `configuration.template`, `configuration.api_url`,
+`configuration.domain` and optional `metadata.template_build`. Docker and microsandbox
+return empty configuration/metadata objects and `credential_configured: false`.
+Unconfigured deployments omit both objects. Native configuration values and secrets
+are never directly serialized; only the adapter's public projection is returned.
+
+`metadata.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`:
the fixed build as Core read it through the pinned SDK when the selection was
saved. GET does not call E2B, so it stays cheap and cannot fail on an E2B outage;
the values describe the immutable build at selection time. Validation admits only
a `ready` build whose CPU count and memory equal the selected `cpus` and
`memory_mib`. `root_disk_mib` is the build's native disk size, which Core does not
-enforce separately. Unknown values are null, including every value of a selection
-saved before Core recorded them; a verified write records them. An omitted-key
+enforce separately. Unknown observed values are null. `metadata: {}` means no build observation
+was recorded. A verified write records the observation. Existing partial observations
+are preserved across schema upgrades and read projections. An omitted-key
identical PUT is a no-op and does not refresh provider metadata.
`suspension` is `{idle_seconds, retention_seconds}` for microsandbox, the only
@@ -248,7 +263,7 @@ retired by a same-provider update. Docker/microsandbox advance only the target;
each node prepares it independently while continuing to serve its qualified old
pin. No execution drain or reenrollment accompanies a target change.
-For E2B PUT, omit `e2b.api_key` to preserve the current key. Omitted-key identical
+For E2B PUT, omit the `credential` object to preserve the current key. Omitted-key identical
selection is a no-op. Explicit nonempty key submission, including the same key,
always verifies and advances generation. Null or empty keys are invalid. A key-only
change uses the same full DTO: provider, existing template, optional resources and
@@ -264,11 +279,11 @@ settled live receipt in the installation-labelled sandbox listing.
A legacy public-template selection without this ownership anchor, or a committed key
that no longer authenticates, requires `409 sandbox_reset_required`; Core cannot
establish a safe online replacement from that state. Keep the old key valid until the
-successful response. A candidate outside the verified team gives `409 e2b_team_mismatch`;
+successful response. A candidate outside the verified team gives `409 sandbox_credential_ownership`;
explicitly reset before initializing another team. Candidate-key 401/403 gives
-`400 e2b_api_key_invalid`; an invalid candidate build gives
-`400 e2b_template_build_invalid`. Missing or unsettled receipts and unconfirmed reads
-fail closed with `503 e2b_request_unconfirmed`. No provider text or credential is
+`400 sandbox_credential_invalid`; an invalid candidate build gives
+`400 sandbox_configuration_invalid`. Missing or unsettled receipts and unconfirmed reads
+fail closed with `503 sandbox_verification_unconfirmed`. No provider text or credential is
returned. The write and `change` or `replace_credential` audit share one transaction.
A credential replacement briefly fences provider calls, waits for actual helper
@@ -454,8 +469,8 @@ fields from the administrator node routes; runtime fields from the
| Field | E2B | Docker | microsandbox |
| --- | --- | --- | --- |
| Deployment `specification.resources` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota | `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib` |
-| Deployment `specification.runtime` | Absent; the build is selected by `e2b.template` | The full [release](#runtime-release); nodes match `image_id` or `image_manifest_digest` | The full [release](#runtime-release); nodes match `microsandbox_ref`, `runtime_sha256` and `firmware_sha256` |
-| Deployment `e2b.template_build` | The build as Core read it when the selection was saved | Absent, with the whole `e2b` object | Absent, with the whole `e2b` object |
+| Deployment `specification.runtime` | Absent; the build is selected by `configuration.template` | The full [release](#runtime-release); nodes match `image_id` or `image_manifest_digest` | The full [release](#runtime-release); nodes match `microsandbox_ref`, `runtime_sha256` and `firmware_sha256` |
+| Deployment `metadata.template_build` | The build as Core read it when the selection was saved | Absent (`metadata` is empty) | Absent (`metadata` is empty) |
| Deployment `suspension` | `null`; Core does not suspend E2B sandboxes | `null` | `{idle_seconds, retention_seconds}` |
| Deployment `resources.allocations`, `resources.pending` | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes | Totals across all nodes |
| Enrollment-token `max_active`, `max_retained` | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | Both limits apply |
diff --git a/docs/api/README.md b/docs/api/README.md
index 6f939f077..ddbdba4c8 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -74,7 +74,7 @@ Errors use the [Core error envelope](../../contracts/agents-api/core-errors.md).
| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md) |
| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](../../contracts/agents-api/installation.md) |
| `metrics` | Core's own process metrics | [Core metrics](../../contracts/agents-api/core-metrics.md) |
-| `sandbox/deployment[/reset]`, `sandbox/e2b/templates[/{template_id}/builds]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, read-only E2B template discovery, node enrollment tokens and nodes | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](../../contracts/agents-api/node-host-history.md) |
+| `sandbox/deployment[/reset]`, `sandbox/providers/{provider}/discovery`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, read-only provider configuration discovery, node enrollment tokens and nodes | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](../../contracts/agents-api/node-host-history.md) |
| `harnesses`, `harnesses/{harness}/model-configuration` | Supported harnesses and each harness's deployment default model configuration (write-only provider key) | [Model execution](../../contracts/agents-api/model-execution.md#deployment-defaults) |
## Machine connection API
diff --git a/docs/api/web-management.md b/docs/api/web-management.md
index a0ec241d0..fbdd23612 100644
--- a/docs/api/web-management.md
+++ b/docs/api/web-management.md
@@ -118,11 +118,11 @@ Response `resources.allocations` and `resources.pending` are cleanup counts, not
CPU, memory or disk settings. E2B accepts a write-only key and exact template build
instead of a node Runtime release, and provisions without a node installation. E2B
may omit `resources` to adopt the validated build's CPU and memory. An E2B-compatible
-service may also supply paired `e2b.api_url` and `e2b.domain`; omitted selectors
+service may also supply paired `configuration.api_url` and `configuration.domain`; omitted selectors
use official E2B. Responses expose these addresses but never the key, and changing
them requires the same drained maintenance transition as changing the template.
Responses show
-the build as read at selection time in `e2b.template_build`. Microsandbox responses
+the build as read at selection time in `metadata.template_build`. Microsandbox responses
return its idle `suspension` policy; other providers return null.
Same-team E2B updates apply online after verification. Existing sandboxes retain
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index c59a14371..9e9d72368 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -202,7 +202,7 @@ A provider must not implement a competing preparation path.
## Register the provider kind
`sandbox/providers/registry.go` is the sole registration table. Each entry binds
-an adapter's specification/resource validators, selection normalization, deployment
+an adapter's specification/resource validators, the required `ConfigurationAdapter`, deployment
mode, defaults, the adapter-owned operation declaration, and local or direct constructor.
`providers.Build` constructs node-local adapters; `providers.BuildDirect` constructs
direct adapters. Neither allocates compute. There is no init-time registration or
@@ -213,7 +213,7 @@ For a new implementation:
1. Implement the operation contracts above in the adapter package and add native contract tests.
2. Add its configuration validators and optional read-only `SelectionDiscoverer`
for native resource discovery. Normalization must copy input before changing it.
- `RestoreSelection` must retain access to owned resources without requiring new
+ `ConfigurationAdapter.Decode` must retain access to owned resources without requiring new
template validation. Put native credential verification behind
`CredentialVerifier` when needed.
3. Register its constructor, policies, operation declaration and defaults in `providers/registry.go`.
@@ -221,18 +221,31 @@ For a new implementation:
The installer projection uses those registered policies and the common field
bounds in `sandbox/deployment_contract.go`; regenerate it with
`go run ./services/agents-api/cmd/specification-contract -write`.
-4. If new configuration fields are necessary, extend the typed `sandbox.Selection`
- envelope and its dedicated encrypted persistence fields, API DTO and operator
- client. Do not replace typed configuration with unrestricted JSON. Field codecs
- may map columns; Store must not parse native endpoints, templates or defaults.
+4. Implement `sandbox.ConfigurationAdapter` with a typed native configuration.
+ `DecodeInput` strictly parses separate `configuration` and write-only `credential`
+ objects. `Encode` creates whitelisted public selectors, read-only observations
+ and separate secret bytes; it must never pass request JSON through. `Decode`
+ restores stored selectors without remote admission. `ResolveChange`, `Equal`
+ and `WithCredential` own inheritance, identity and credential composition.
+ `Requirements` explicitly declares credential and public-origin needs plus
+ discovery support. Node providers accept only an empty public object, reject
+ credentials and explicitly return Unsupported for discovery and replacement.
+ Implement the separate `ConfigurationDiscoverer` interface even when unsupported.
+ Discovery owns query validation and safe catalog results, never mutations or
+ deployment admission. Core keeps admin authorization, input limits and deadlines.
+ A new kind requires no vendor column, API field or Store branch.
5. Supply required installer/distribution artifacts and operator labels. A new
provider must not add a Session/Turn scheduling path or a Store vendor switch.
Preview and persistence use `providers.Normalize` and `providers.Describe`.
`SelectionDiscoverer` resolves omitted native resource values before commit; the
complete specification is validated again at persistence. Store owns transactions,
-credential encryption, generation fencing, resource ownership and typed column
-mapping. Database constraints validate structure, not the registration list.
+credential encryption, generation fencing, resource ownership and generic object storage. The adapter alone interprets
+`provider_config` and `provider_metadata`; `provider_credential` holds ciphertext
+bound to the installation and generation. Retained generations store their original
+public configuration and metadata, and compose the current credential through the
+adapter. No retained selector is rewritten by a credential replacement. Database
+constraints validate object structure, not the registration list.
`providers.ResolveChange` owns configuration inheritance and comparison uses
normalized selectors, so preview, retry and commit share the same defaults.
diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md
index 1f4d15698..b0ade1780 100644
--- a/docs/web/protocol-coverage.md
+++ b/docs/web/protocol-coverage.md
@@ -144,7 +144,7 @@ consumed; the list carries each provider.
| Operation | Route | Console use |
| --- | --- | --- |
-| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `e2b.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary |
+| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (config.json's `public_url`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows on System, the Sandbox backend summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary |
| Reset | `POST/DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources or cancel the remaining clear at the observed generation; consume Core’s remaining/offline projection |
| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health; an empty `core_url` (a node Core did not enroll) is unknown, not old. **Add node** follows only the node whose `enrollment_id` equals its command's; a node enrolled before Core recorded it reports null and never matches |
| Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | Sandbox metrics node dialog: the host's CPU busy share and memory from its last heartbeat, and their history over the page's range. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit |
diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts
index 1c4e98186..24564e733 100644
--- a/packages/agents-client/src/sandbox-client.test.ts
+++ b/packages/agents-client/src/sandbox-client.test.ts
@@ -39,8 +39,10 @@ const allocation = {
state: "running", compute_phase: "running", compute_phase_changed_at: null, diagnostic: "", initialization: "ready", created_at: created,
};
const runtime = { source_commit: "a".repeat(40), image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64), microsandbox_ref: "oac-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) };
-const unconfigured = { rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null };
+const unconfigured = {
+ credential_configured: false, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null };
const docker = {
+ configuration: {}, metadata: {},
...unconfigured, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes",
resources: { allocations: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 }, runtime }, specification_digest: "0".repeat(64),
};
@@ -51,7 +53,7 @@ const microsandbox = {
/** An E2B selection saved before Core recorded its template build. */
const e2bDeployment = {
...docker, provider: "e2b", rollout: unconfigured.rollout, mode: "direct", specification: { resources: { cpus: 2, memory_mib: 2048 } },
- e2b: { template: "runtime:00000000-0000-0000-0000-000000000001", api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status: null, resources: { cpus: null, memory_mib: null, root_disk_mib: null } } },
+ 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> = {
nodes: (client) => client.listNodes(),
@@ -94,7 +96,7 @@ describe("strict sandbox administration projections", () => {
["allocations", "another node's allocation", { data: [{ ...allocation, node_id: unready.id }] }],
["deployment", "a missing suspension", unsuspended],
["deployment", "a specification without its digest", undigested],
- ["deployment", "an e2b member for Docker", { ...docker, e2b: e2bDeployment.e2b }],
+ ["deployment", "an e2b member for Docker", { ...docker, configuration: e2bDeployment.configuration }],
["deployment", "E2B without its e2b member", { ...docker, provider: "e2b", mode: "direct" }],
["deployment", "an unknown mode", { ...docker, mode: "hybrid" }],
])("rejects %s with %s", async (name, _, body) => {
@@ -102,7 +104,7 @@ describe("strict sandbox administration projections", () => {
});
it("never passes a reflected E2B key through", async () => {
- const reflected = { ...e2bDeployment, e2b: { ...e2bDeployment.e2b, api_key: "e2b-private-key" } };
+ const reflected = { ...e2bDeployment, configuration: { ...e2bDeployment.configuration, api_key: "e2b-private-key" } };
const error = await read("deployment", reflected).catch((caught: unknown) => caught);
expect(error).toMatchObject({ status: 502, code: "invalid_admin_response" });
expect(JSON.stringify(error) + String(error)).not.toContain("e2b-private-key");
@@ -118,7 +120,7 @@ describe("Core sandbox credential boundaries", () => {
const input = { api_key: "private-test-key", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" };
expect(await client.listE2BTemplates(input)).toHaveLength(1);
expect(await client.listE2BReadyBuilds("tpl_123", input)).toHaveLength(1);
- expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/core/v1/sandbox/e2b/templates", "/core/v1/sandbox/e2b/templates/tpl_123/builds"]);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/core/v1/sandbox/providers/e2b/discovery", "/core/v1/sandbox/providers/e2b/discovery"]);
expect(fetch.mock.calls.every(([, init]) => init?.method === "POST" && !new Headers(init?.headers).has("Authorization") && String(init?.body).includes("private-test-key"))).toBe(true);
});
@@ -196,13 +198,13 @@ describe("Core sandbox credential boundaries", () => {
it.each([409, 503])("does not retry initialization after HTTP %s", async (status) => {
const fetch = vi.fn().mockResolvedValue(response({ error: { message: "Setup failed", code: "sandbox_deployment_conflict" } }, status));
const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin", fetch });
- await expect(admin.initializeDeployment({ expected_generation: 0, provider: "microsandbox" })).rejects.toThrow("Setup failed");
+ await expect(admin.initializeDeployment({ expected_generation: 0, provider: "microsandbox" })).rejects.toThrow("The sandbox deployment cannot change in its current state.");
expect(fetch).toHaveBeenCalledTimes(1);
});
it("does not retry an uncertain initialization transport failure", async () => {
const fetch = vi.fn().mockRejectedValue(new TypeError("Connection lost"));
const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin", fetch });
- await expect(admin.initializeDeployment({ expected_generation: 0, provider: "docker" })).rejects.toThrow("Connection lost");
+ await expect(admin.initializeDeployment({ expected_generation: 0, provider: "docker" })).rejects.toThrow("Sandbox configuration could not be confirmed.");
expect(fetch).toHaveBeenCalledTimes(1);
});
it("uses the explicit admin credential and admin routes without a project beta header", async () => {
@@ -234,19 +236,19 @@ describe("hosted provider configuration", () => {
it("writes E2B configuration and generation without beta headers or browser credentials", async () => {
const deployment = {
...e2bDeployment, generation: 2, resources: { allocations: 0, pending: 0 },
- e2b: { template: e2b.template, api_url: "https://api.e2b.app", domain: "e2b.app", credential_configured: true, template_build: { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 24063 } } },
+ configuration: { template: e2b.template, api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 24063 } } },
};
const fetch = vi.fn().mockImplementation(async () => response(deployment));
const client = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch });
const controller = new AbortController();
// Omitted E2B resources are filled from the validated template build.
- expect(await client.initializeDeployment({ expected_generation: 0, provider: "e2b", e2b })).toEqual(deployment);
- await client.updateDeployment({ provider: "e2b", e2b, expected_generation: 1 }, { signal: controller.signal });
+ expect(await client.initializeDeployment({ expected_generation: 0, provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key } })).toEqual(deployment);
+ await client.updateDeployment({ provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key }, expected_generation: 1 }, { signal: controller.signal });
await client.cancelReset(2);
expect(fetch.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
["/core/v1/sandbox/deployment", "POST"], ["/core/v1/sandbox/deployment", "PUT"], ["/core/v1/sandbox/deployment/reset?expected_generation=2", "DELETE"],
]);
- expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ provider: "e2b", e2b, expected_generation: 1 });
+ expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key }, expected_generation: 1 });
expect(fetch.mock.calls[1]?.[1]?.signal).toBe(controller.signal);
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
for (const [, init] of fetch.mock.calls) {
@@ -257,8 +259,8 @@ describe("hosted provider configuration", () => {
it.each([409, 503])("does not expose reflected E2B keys or retry configuration after HTTP %s", async (status) => {
const fetch = vi.fn().mockResolvedValue(response({ error: { message: e2b.api_key, code: e2b.api_key, param: e2b.api_key } }, status));
const client = new SandboxAdminClient({ fetch });
- await expect(client.updateDeployment({ provider: "e2b", e2b, expected_generation: 1 })).rejects.toMatchObject({ code: "sandbox_configuration_unconfirmed", status });
- await client.initializeDeployment({ expected_generation: 0, provider: "e2b", e2b }).catch((error) => {
+ await expect(client.updateDeployment({ provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key }, expected_generation: 1 })).rejects.toMatchObject({ code: "sandbox_configuration_unconfirmed", status });
+ await client.initializeDeployment({ expected_generation: 0, provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key } }).catch((error) => {
expect(JSON.stringify(error)).not.toContain(e2b.api_key);
expect(error.message).not.toContain(e2b.api_key);
});
@@ -270,10 +272,10 @@ describe("hosted provider configuration", () => {
.mockResolvedValueOnce(response({ error: rejection }, 409))
.mockResolvedValueOnce(response({ error: { ...rejection, message: rejection.message + e2b.api_key } }, 409));
const client = new SandboxAdminClient({ fetch });
- const shown = await client.initializeDeployment({ expected_generation: 0, provider: "e2b", e2b }).catch((error: unknown) => error);
+ const shown = await client.initializeDeployment({ expected_generation: 0, provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key } }).catch((error: unknown) => error);
expect(shown).toMatchObject({ status: 409, code: "sandbox_configuration_error", message: rejection.message, param: null });
expect((shown as AgentCoreError).errorType).toBeUndefined();
- await expect(client.initializeDeployment({ expected_generation: 0, provider: "e2b", e2b })).rejects.toMatchObject({ status: 409, code: "sandbox_configuration_error", message: rejection.message, param: null });
+ await expect(client.initializeDeployment({ expected_generation: 0, provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key } })).rejects.toMatchObject({ status: 409, code: "sandbox_configuration_error", message: rejection.message, param: null });
});
it("keeps legacy E2B ownership reset actionable without reflecting either key", async () => {
const fetch = vi.fn().mockResolvedValue(response({ error: {
@@ -281,7 +283,7 @@ describe("hosted provider configuration", () => {
param: "revoked-old-key", details: { current_provider: "e2b", requested_provider: "e2b", secret: e2b.api_key },
} }, 409));
const client = new SandboxAdminClient({ fetch });
- const error = await client.updateDeployment({ provider: "e2b", e2b, expected_generation: 1 }).catch((error: unknown) => error);
+ const error = await client.updateDeployment({ provider: "e2b", configuration: { template: e2b.template }, credential: { api_key: e2b.api_key }, expected_generation: 1 }).catch((error: unknown) => error);
expect(error).toMatchObject({ status: 409, code: "sandbox_reset_required", message: "Reset the sandbox deployment before changing this configuration.", param: null });
expect((error as AgentCoreError).details).toBeUndefined();
expect(JSON.stringify(error)).not.toContain("revoked-old-key");
@@ -339,7 +341,7 @@ describe("durable reset projection", () => {
details: { current_generation: 7, arbitrary: secret },
} }, 409));
const error = await new SandboxAdminClient({ fetch }).updateDeployment({ provider: "e2b", expected_generation: 1,
- e2b: { api_key: secret, template: "runtime:00000000-0000-0000-0000-000000000001" } }).catch(error => error);
+ credential: { api_key: secret }, configuration: { template: "runtime:00000000-0000-0000-0000-000000000001" } }).catch(error => error);
expect(error).toMatchObject({ code: "sandbox_generation_stale", status: 409, details: { current_generation: 7 } });
expect(JSON.stringify(error)).not.toContain(secret);
expect(error.message).not.toContain(secret);
@@ -365,18 +367,18 @@ it("uses the authoritative rollout signal instead of retained old Sessions for p
it("omits a preserved key and submits an explicit same key once without retry", async () => {
const fetch = vi.fn().mockImplementation(async () => response(e2bDeployment));
const client = new SandboxAdminClient({ fetch });
- await client.updateDeployment({ provider: "e2b", expected_generation: 1, e2b: { template: e2bDeployment.e2b.template } });
- await client.updateDeployment({ provider: "e2b", expected_generation: 1, e2b: { template: e2bDeployment.e2b.template, api_key: "same-key" } });
+ await client.updateDeployment({ provider: "e2b", expected_generation: 1, configuration: { template: e2bDeployment.configuration.template } });
+ await client.updateDeployment({ provider: "e2b", expected_generation: 1, configuration: { template: e2bDeployment.configuration.template }, credential: { api_key: "same-key" } });
expect(fetch).toHaveBeenCalledTimes(2);
- expect(JSON.parse(String(fetch.mock.calls[0]![1]!.body)).e2b).not.toHaveProperty("api_key");
- expect(JSON.parse(String(fetch.mock.calls[1]![1]!.body)).e2b.api_key).toBe("same-key");
+ expect(JSON.parse(String(fetch.mock.calls[0]![1]!.body))).not.toHaveProperty("credential");
+ expect(JSON.parse(String(fetch.mock.calls[1]![1]!.body)).credential.api_key).toBe("same-key");
});
it("keeps omitted-key public-URL errors actionable without reflecting a stored key", async () => {
const secret = "previously-stored-secret";
const fetch = vi.fn().mockResolvedValue(response({ error: { code: "sandbox_configuration_error", message: `arbitrary upstream ${secret}`, param: secret, details: { credential: secret } } }, 409));
const client = new SandboxAdminClient({ fetch });
- const error = await client.updateDeployment({ provider: "e2b", expected_generation: 1, e2b: { template: e2bDeployment.e2b.template } }).catch(error => error);
+ const error = await client.updateDeployment({ provider: "e2b", expected_generation: 1, configuration: { template: e2bDeployment.configuration.template } }).catch(error => error);
expect(error).toMatchObject({ status: 409, code: "sandbox_configuration_error", param: null, message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback." });
expect(JSON.stringify(error)).not.toContain(secret);
expect(error.message).not.toContain(secret);
diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts
index 241a87138..9babc0a8c 100644
--- a/packages/agents-client/src/sandbox-client.ts
+++ b/packages/agents-client/src/sandbox-client.ts
@@ -30,12 +30,11 @@ export interface InitializeSandboxDeployment {
resources?: SandboxResources;
/** Required for Docker/microsandbox; E2B uses its fixed template build. */
runtime?: SandboxRuntimeRelease;
- e2b?: { api_key: string; template: string; api_url?: string; domain?: string };
-}
-export interface UpdateSandboxDeployment extends Omit {
- /** Omit api_key to preserve the current key. Supplying it, even unchanged, verifies and replaces it once. */
- e2b?: { api_key?: string; template: string; api_url?: string; domain?: string };
+ configuration?: { template?: string; api_url?: string; domain?: string };
+ /** Write-only. Omission on update preserves the current credential. */
+ credential?: { api_key: string };
}
+export interface UpdateSandboxDeployment extends InitializeSandboxDeployment {}
export interface SandboxRollout {
/** Poll at high frequency only while preparing, independently of old Session retention. */
state: "settled" | "preparing";
@@ -74,7 +73,9 @@ export interface SandboxDeployment {
generation: number;
mode: "nodes" | "direct" | "";
resources: { allocations: number; pending: number };
- e2b?: { template: string; api_url: string; domain: string; credential_configured: boolean; template_build: SandboxE2BTemplateBuild };
+ configuration?: { template?: string; api_url?: string; domain?: string };
+ metadata?: { template_build?: SandboxE2BTemplateBuild };
+ credential_configured: boolean;
/** Idle suspension policy; microsandbox only, otherwise null. */
suspension: { idle_seconds: number; retention_seconds: number } | null;
}
@@ -197,20 +198,16 @@ function projectSpecification(value: unknown): SandboxSpecification {
valid(strings(runtime, releaseFields));
return { resources: { ...resources } as unknown as SandboxResources, runtime: { ...runtime } as unknown as SandboxRuntimeRelease };
}
-/** The safe E2B view; it has no key member. */
-function projectE2B(value: unknown): NonNullable {
- const e2b = members(value, ["template", "credential_configured", "template_build"], ["api_url", "domain"]);
- const build = members(e2b.template_build, ["status", "resources"]);
+/** The adapter's public projection has no credential member. */
+function projectE2B(configuration: unknown, metadata: unknown): Pick {
+ const config = members(configuration, ["template", "api_url", "domain"]);
+ valid(strings(config, ["template", "api_url", "domain"]));
+ const facts = members(metadata, [], ["template_build"]);
+ if (!hasOwn(facts, "template_build")) return { configuration: { ...config }, metadata: {} };
+ const build = members(facts.template_build, ["status", "resources"]);
const resources = members(build.resources, ["cpus", "memory_mib", "root_disk_mib"]);
- valid(typeof e2b.template === "string" && typeof e2b.credential_configured === "boolean" &&
- (!hasOwn(e2b, "api_url") || typeof e2b.api_url === "string") && (!hasOwn(e2b, "domain") || typeof e2b.domain === "string") &&
- (hasOwn(e2b, "api_url") === hasOwn(e2b, "domain")) && (build.status === null || typeof build.status === "string") &&
- Object.values(resources).every(nullable(isNonnegativeInteger)));
- return {
- template: e2b.template as string, api_url: (e2b.api_url as string | undefined) ?? "https://api.e2b.app",
- domain: (e2b.domain as string | undefined) ?? "e2b.app", credential_configured: e2b.credential_configured as boolean,
- template_build: { status: build.status as string | null, resources: { ...resources } as SandboxE2BTemplateBuild["resources"] },
- };
+ valid((build.status === null || typeof build.status === "string") && Object.values(resources).every(nullable(isNonnegativeInteger)));
+ return { configuration: { ...config }, metadata: { template_build: { status: build.status as string | null, resources: { ...resources } as SandboxE2BTemplateBuild["resources"] } } };
}
/** Counts and blocker identities are one Core snapshot, never reconstructed from node lists. */
function projectReset(value: unknown, held: number): SandboxReset | null {
@@ -245,11 +242,13 @@ function projectNodeRollout(value: unknown, online: unknown): SandboxNodeRollout
(rollout.diagnostic === undefined || (typeof rollout.diagnostic === "string" && rollout.diagnostic !== "" && rollout.state === "failed")));
return { ...rollout, ...(rollout.diagnostic !== undefined ? { diagnostic: nodeDiagnostics.has(rollout.diagnostic as string) ? rollout.diagnostic : "provider_unavailable" } : {}) } as unknown as SandboxNodeRollout;
}
-/** `specification` and `specification_digest` appear together once configured; `e2b` appears exactly for E2B. */
+/** Configured deployments carry a validated public configuration and observation object. */
function projectDeployment(value: unknown): SandboxDeployment {
const e2b = isRecord(value) && value.provider === "e2b";
- const fields = ["installation_id", "provider", "core_url", "reset", "rollout", "owner_epoch", "generation", "mode", "resources", "suspension"];
- const deployment = members(value, e2b ? [...fields, "e2b"] : fields, ["specification", "specification_digest"]);
+ const fields = ["installation_id", "provider", "core_url", "reset", "rollout", "owner_epoch", "generation", "mode", "resources", "suspension", "credential_configured"];
+ const deployment = members(value, isRecord(value) && value.provider !== "" ? [...fields, "configuration", "metadata"] : fields, ["specification", "specification_digest"]);
+ valid(typeof deployment.credential_configured === "boolean");
+ if (!e2b && deployment.provider !== "") { members(deployment.configuration, []); members(deployment.metadata, []); valid(deployment.credential_configured === false); }
const resources = members(deployment.resources, ["allocations", "pending"]);
const suspension = deployment.suspension === null ? null : members(deployment.suspension, ["idle_seconds", "retention_seconds"]);
const configured = hasOwn(deployment, "specification");
@@ -260,7 +259,7 @@ function projectDeployment(value: unknown): SandboxDeployment {
return {
...deployment, rollout: projectRollout(deployment.rollout, deployment.mode, Number(resources.allocations) + Number(resources.pending)), reset: projectReset(deployment.reset, Number(resources.allocations) + Number(resources.pending)), resources: { ...resources } as SandboxDeployment["resources"], suspension: suspension && { ...suspension } as SandboxDeployment["suspension"],
...(configured ? { specification: projectSpecification(deployment.specification) } : {}),
- ...(e2b ? { e2b: projectE2B(deployment.e2b) } : {}),
+ ...(e2b ? projectE2B(deployment.configuration, deployment.metadata) : {}),
} as unknown as SandboxDeployment;
}
@@ -323,7 +322,7 @@ export class SandboxAdminClient {
}
async listE2BTemplates(input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise {
- const value = await this.#core.json("/e2b/templates", options, "POST", input);
+ const value = await this.#core.json("/providers/e2b/discovery", options, "POST", { configuration: { api_url: input.api_url, domain: input.domain }, credential: { api_key: input.api_key }, query: {} });
if (!isRecord(value) || !onlyFields(value, new Set(["templates"])) || !Array.isArray(value.templates) || value.templates.length > 200) invalidSandboxResponse();
return value.templates.map((item) => {
if (!isRecord(item) || !onlyFields(item, new Set(["id", "names"])) || typeof item.id !== "string" || !Array.isArray(item.names) || !item.names.every((name) => typeof name === "string")) invalidSandboxResponse();
@@ -331,7 +330,7 @@ export class SandboxAdminClient {
});
}
async listE2BReadyBuilds(templateId: string, input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise {
- const value = await this.#core.json(`/e2b/templates/${encodeURIComponent(templateId)}/builds`, options, "POST", input);
+ const value = await this.#core.json("/providers/e2b/discovery", options, "POST", { configuration: { api_url: input.api_url, domain: input.domain }, credential: { api_key: input.api_key }, query: { template: templateId } });
if (!isRecord(value) || !onlyFields(value, new Set(["builds"])) || !Array.isArray(value.builds) || value.builds.length > 200) invalidSandboxResponse();
return value.builds.map((item) => {
if (!isRecord(item) || !onlyFields(item, new Set(["id", "cpus", "memory_mib"])) || typeof item.id !== "string" || !isNonnegativeInteger(item.cpus) || !isNonnegativeInteger(item.memory_mib)) invalidSandboxResponse();
@@ -359,21 +358,22 @@ export class SandboxAdminClient {
}
async #writeDeployment(method: "POST" | "PUT", input: InitializeSandboxDeployment | UpdateSandboxDeployment, options?: ReadOptions): Promise {
try {
- // As with an unparsable body, an E2B write with an invalid response is unconfirmed.
+ // As with an unparsable body, a configuration write with an invalid response is unconfirmed.
return projectDeployment(await this.#core.json("/deployment", options, method, input));
} catch (error) {
- if (!input.e2b) throw error;
if (error instanceof AgentCoreError && [400, 409, 503].includes(error.status)) {
const messages: Record = {
+ sandbox_specification_mismatch: "The node specification differs from the deployment.",
+ sandbox_deployment_conflict: "The sandbox deployment cannot change in its current state.",
sandbox_generation_stale: "The sandbox configuration changed. Refresh before submitting again.",
sandbox_reset_required: "Reset the sandbox deployment before changing this configuration.",
sandbox_reset_in_progress: "A sandbox reset is in progress.",
sandbox_not_configured: "The sandbox deployment is not configured.",
sandbox_in_use: "Hosted sandbox resources still belong to this deployment.",
- e2b_team_mismatch: "This E2B key cannot manage the retained deployment. Reset before changing teams.",
- e2b_api_key_invalid: "The E2B API key was rejected.",
- e2b_template_build_invalid: "Select a ready immutable E2B template build with matching resources.",
- e2b_request_unconfirmed: "E2B verification could not be confirmed. Refresh before submitting again.",
+ sandbox_credential_ownership: "This E2B key cannot manage the retained deployment. Reset before changing teams.",
+ sandbox_credential_invalid: "The E2B API key was rejected.",
+ sandbox_configuration_invalid: "Select a ready immutable E2B template build with matching resources.",
+ sandbox_verification_unconfirmed: "E2B verification could not be confirmed. Refresh before submitting again.",
};
if (error.code && Object.hasOwn(messages, error.code)) {
// Credential-bearing errors expose fixed local copy and allowlisted
@@ -381,8 +381,8 @@ export class SandboxAdminClient {
const fields = error.code === "sandbox_generation_stale" ? ["current_generation"] : error.code === "sandbox_in_use" ? ["allocations", "pending"] : [];
const details = Object.fromEntries(fields.filter(field => isNonnegativeInteger(error.details?.[field])).map(field => [field, Number(error.details![field])]));
const safeParam = error.status === 400
- ? error.code === "e2b_api_key_invalid" ? "e2b.api_key" : error.code === "e2b_template_build_invalid" ? "e2b.template" : null
- : error.status === 409 && error.code === "e2b_team_mismatch" ? "e2b.api_key" : null;
+ ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : null
+ : error.status === 409 && error.code === "sandbox_credential_ownership" ? "credential" : null;
const param = error.param === safeParam ? safeParam : null;
throw new AgentCoreError(messages[error.code]!, error.status, error.code, param, undefined, Object.keys(details).length ? details : undefined);
}
diff --git a/packages/agents-client/src/sandbox-e2b-error-params.test.ts b/packages/agents-client/src/sandbox-e2b-error-params.test.ts
index a62db9b69..608a1d372 100644
--- a/packages/agents-client/src/sandbox-e2b-error-params.test.ts
+++ b/packages/agents-client/src/sandbox-e2b-error-params.test.ts
@@ -3,9 +3,9 @@ import { AgentCoreError } from "./client";
import { SandboxAdminClient } from "./sandbox-client";
const cases = [
- { status: 400, code: "e2b_api_key_invalid", param: "e2b.api_key", message: "The E2B API key was rejected." },
- { status: 400, code: "e2b_template_build_invalid", param: "e2b.template", message: "Select a ready immutable E2B template build with matching resources." },
- { status: 409, code: "e2b_team_mismatch", param: "e2b.api_key", message: "This E2B key cannot manage the retained deployment. Reset before changing teams." },
+ { status: 400, code: "sandbox_credential_invalid", param: "credential", message: "The E2B API key was rejected." },
+ { status: 400, code: "sandbox_configuration_invalid", param: "configuration", message: "Select a ready immutable E2B template build with matching resources." },
+ { status: 409, code: "sandbox_credential_ownership", param: "credential", message: "This E2B key cannot manage the retained deployment. Reset before changing teams." },
] as const;
const currentKey = "current-secret-canary";
const storedKey = "stored-secret-canary";
@@ -19,8 +19,8 @@ async function reject(input: Rejection, method: "POST" | "PUT" = "PUT", includeK
const client = new SandboxAdminClient({ fetch });
const e2b = { template: "runtime:00000000-0000-0000-0000-000000000001", ...(includeKey ? { api_key: currentKey } : {}) };
const result = await (method === "POST"
- ? client.initializeDeployment({ provider: "e2b", expected_generation: 0, e2b: { ...e2b, api_key: currentKey } })
- : client.updateDeployment({ provider: "e2b", expected_generation: 1, e2b })).catch((error: unknown) => error);
+ ? client.initializeDeployment({ provider: "e2b", expected_generation: 0, configuration: { template: e2b.template }, credential: { api_key: currentKey } })
+ : client.updateDeployment({ provider: "e2b", expected_generation: 1, configuration: { template: e2b.template }, ...(e2b.api_key ? { credential: { api_key: e2b.api_key } } : {}) })).catch((error: unknown) => error);
expect(result).toBeInstanceOf(AgentCoreError);
const error = result as AgentCoreError;
expect(fetch).toHaveBeenCalledTimes(1);
@@ -44,7 +44,7 @@ describe("safe E2B deployment error parameters", () => {
expect(await reject(input, "PUT", false)).toMatchObject(input);
});
it.each(cases)("drops mismatched or reflected parameters for $code", async (input) => {
- for (const param of [undefined, null, "", "e2b", "e2b.api_key.extra", input.param === "e2b.api_key" ? "e2b.template" : "e2b.api_key", currentKey, storedKey, [input.param], { field: input.param }]) {
+ for (const param of [undefined, null, "", "e2b", "e2b.api_key.extra", input.param === "credential" ? "configuration" : "credential", currentKey, storedKey, [input.param], { field: input.param }]) {
for (const includeKey of [true, false]) {
expect(await reject({ ...input, param }, "PUT", includeKey)).toMatchObject({ status: input.status, code: input.code, message: input.message, param: null });
}
@@ -55,12 +55,12 @@ describe("safe E2B deployment error parameters", () => {
expect(await reject({ ...input, status })).toMatchObject({ status, code: input.code, message: input.message, param: null });
}
});
- it.each(["e2b.api_key", "e2b.template", reflected])("keeps unconfirmed requests unscoped despite parameter %s", async (param) => {
- expect(await reject({ status: 503, code: "e2b_request_unconfirmed", param })).toMatchObject({ code: "e2b_request_unconfirmed", param: null });
+ it.each(["credential", "configuration", reflected])("keeps unconfirmed requests unscoped despite parameter %s", async (param) => {
+ expect(await reject({ status: 503, code: "sandbox_verification_unconfirmed", param })).toMatchObject({ code: "sandbox_verification_unconfirmed", param: null });
});
it.each(["unknown", currentKey, storedKey, "constructor", "__proto__", "toString"])("replaces unknown credential-bearing code %s without replay", async (code) => {
for (const includeKey of [true, false]) {
- const error = await reject({ status: 400, code, param: "e2b.api_key", details: { secret: reflected } }, "PUT", includeKey);
+ const error = await reject({ status: 400, code, param: "credential", details: { secret: reflected } }, "PUT", includeKey);
expect(error).toMatchObject({ code: "sandbox_configuration_unconfirmed" });
expect(error.param).toBeUndefined();
expect(error.details).toBeUndefined();
@@ -71,7 +71,7 @@ describe("safe E2B deployment error parameters", () => {
{ code: "sandbox_in_use", details: { current_generation: 4, allocations: 2, pending: 1, secret: reflected }, expected: { allocations: 2, pending: 1 } },
{ code: "sandbox_in_use", details: { allocations: -1, pending: reflected }, expected: undefined },
])("retains the numeric detail allowlist for $code", async ({ code, details, expected }) => {
- const error = await reject({ status: 409, code, param: "e2b.api_key", details });
+ const error = await reject({ status: 409, code, param: "credential", details });
expect(error.param).toBeNull();
expect(error.details).toEqual(expected);
});
diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go
index 8a8339ec7..b2e63f685 100644
--- a/services/agents-api/cmd/server/managed_setup.go
+++ b/services/agents-api/cmd/server/managed_setup.go
@@ -82,7 +82,7 @@ func (s *managedSetup) load(ctx context.Context) (*execution.RuntimeProvider, er
}
func (s *managedSetup) prepare(ctx context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) {
- // E2B guests reach Core from E2B's cloud, over the internet.
+ // Adapters declare whether their guests require a public Core origin.
adapter, err := providers.Lookup(setup.Provider)
if err != nil {
return execution.PreparedRuntimeDeployment{}, err
diff --git a/services/agents-api/cmd/server/runtime_history_test.go b/services/agents-api/cmd/server/runtime_history_test.go
index 4c1d04ade..52cba81ad 100644
--- a/services/agents-api/cmd/server/runtime_history_test.go
+++ b/services/agents-api/cmd/server/runtime_history_test.go
@@ -2,14 +2,13 @@ package main
import (
"context"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
"os"
"path/filepath"
"strings"
"testing"
"time"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs"
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
)
type panicHistoryExporter struct{}
diff --git a/services/agents-api/cmd/specification-contract/main.go b/services/agents-api/cmd/specification-contract/main.go
index 96a75fb11..67f445eac 100644
--- a/services/agents-api/cmd/specification-contract/main.go
+++ b/services/agents-api/cmd/specification-contract/main.go
@@ -4,10 +4,9 @@ package main
import (
"flag"
"fmt"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
"os"
"strings"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
)
func main() {
diff --git a/services/agents-api/internal/api/items.go b/services/agents-api/internal/api/items.go
index 05d099c62..119ad16a0 100644
--- a/services/agents-api/internal/api/items.go
+++ b/services/agents-api/internal/api/items.go
@@ -1,9 +1,8 @@
package api
import (
- "net/http"
-
"github.com/go-chi/chi/v5"
+ "net/http"
)
// @Summary List persisted execution Items
diff --git a/services/agents-api/internal/api/sandbox_node_configuration.go b/services/agents-api/internal/api/sandbox_node_configuration.go
index a8f308e5f..cdd6370e1 100644
--- a/services/agents-api/internal/api/sandbox_node_configuration.go
+++ b/services/agents-api/internal/api/sandbox_node_configuration.go
@@ -1,11 +1,10 @@
package api
import (
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
"math"
"net/http"
"strconv"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
)
// @Summary Read the active configuration for node installation
diff --git a/services/agents-api/internal/api/sandbox_node_detail_test.go b/services/agents-api/internal/api/sandbox_node_detail_test.go
index dcea0b0e8..bcad20c2b 100644
--- a/services/agents-api/internal/api/sandbox_node_detail_test.go
+++ b/services/agents-api/internal/api/sandbox_node_detail_test.go
@@ -1,10 +1,9 @@
package api
import (
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
+ "testing"
)
func TestSandboxNodeDetailValidationAndAuthentication(t *testing.T) {
diff --git a/services/agents-api/internal/engine/mcp_test.go b/services/agents-api/internal/engine/mcp_test.go
index 0fb9cb017..be70bad26 100644
--- a/services/agents-api/internal/engine/mcp_test.go
+++ b/services/agents-api/internal/engine/mcp_test.go
@@ -1,10 +1,9 @@
package engine
import (
- "testing"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
+ "testing"
)
func TestMCPOriginQualification(t *testing.T) {
diff --git a/services/agents-api/internal/execution/environment_capabilities_test.go b/services/agents-api/internal/execution/environment_capabilities_test.go
index e43bd6ac8..c9e48bf29 100644
--- a/services/agents-api/internal/execution/environment_capabilities_test.go
+++ b/services/agents-api/internal/execution/environment_capabilities_test.go
@@ -2,11 +2,10 @@ package execution
import (
"encoding/json"
- "slices"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
+ "slices"
+ "testing"
)
func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) {
diff --git a/services/agents-api/internal/execution/environment_test.go b/services/agents-api/internal/execution/environment_test.go
index f78e32e1b..6a8ce5019 100644
--- a/services/agents-api/internal/execution/environment_test.go
+++ b/services/agents-api/internal/execution/environment_test.go
@@ -1,9 +1,8 @@
package execution
import (
- "testing"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
+ "testing"
)
func TestExecutionEnvironmentDoesNotDefaultToLocal(t *testing.T) {
diff --git a/services/agents-api/internal/execution/runtime_capabilities_test.go b/services/agents-api/internal/execution/runtime_capabilities_test.go
index 063f6fb2b..c141327cc 100644
--- a/services/agents-api/internal/execution/runtime_capabilities_test.go
+++ b/services/agents-api/internal/execution/runtime_capabilities_test.go
@@ -4,13 +4,12 @@ import (
"bytes"
"context"
"errors"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
"github.com/MiniMax-AI-Dev/parsar/internal/agentplugin"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
"github.com/google/uuid"
+ "testing"
)
type capabilityFixture struct {
diff --git a/services/agents-api/internal/runtimeenrollment/connection_test.go b/services/agents-api/internal/runtimeenrollment/connection_test.go
index 49cdf7917..4b02ff7e3 100644
--- a/services/agents-api/internal/runtimeenrollment/connection_test.go
+++ b/services/agents-api/internal/runtimeenrollment/connection_test.go
@@ -3,15 +3,14 @@ package runtimeenrollment
import (
"context"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
+ "github.com/gorilla/websocket"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
- "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
- "github.com/gorilla/websocket"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
diff --git a/services/agents-api/internal/runtimeobs/operations_test.go b/services/agents-api/internal/runtimeobs/operations_test.go
index 45dfed813..50f30ea98 100644
--- a/services/agents-api/internal/runtimeobs/operations_test.go
+++ b/services/agents-api/internal/runtimeobs/operations_test.go
@@ -3,10 +3,9 @@ package runtimeobs
import (
"context"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"testing"
"time"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
)
type failingBatchSource struct {
diff --git a/services/agents-api/internal/runtimeobs/service.go b/services/agents-api/internal/runtimeobs/service.go
index d44f56408..08188e606 100644
--- a/services/agents-api/internal/runtimeobs/service.go
+++ b/services/agents-api/internal/runtimeobs/service.go
@@ -4,12 +4,11 @@ import (
"context"
"errors"
"fmt"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"reflect"
"regexp"
"sync"
"time"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
)
var providerTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`)
diff --git a/services/agents-api/internal/sandbox/configuration.go b/services/agents-api/internal/sandbox/configuration.go
index 700c8facb..9f9e532a5 100644
--- a/services/agents-api/internal/sandbox/configuration.go
+++ b/services/agents-api/internal/sandbox/configuration.go
@@ -84,8 +84,8 @@ func DecodeConfigurationObject(raw json.RawMessage, target any, allowed ...strin
if json.Unmarshal(raw, &fields) != nil || fields == nil {
return ErrInvalid
}
- for field := range fields {
- if !slices.Contains(allowed, field) {
+ for field, value := range fields {
+ if !slices.Contains(allowed, field) || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return ErrInvalid
}
}
diff --git a/services/agents-api/internal/sandbox/contracttest/provider.go b/services/agents-api/internal/sandbox/contracttest/provider.go
index 8201316c8..8c7555f53 100644
--- a/services/agents-api/internal/sandbox/contracttest/provider.go
+++ b/services/agents-api/internal/sandbox/contracttest/provider.go
@@ -5,12 +5,11 @@ package contracttest
import (
"context"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"reflect"
"testing"
"time"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
)
type Fault string
diff --git a/services/agents-api/internal/sandbox/e2b/configuration_test.go b/services/agents-api/internal/sandbox/e2b/configuration_test.go
new file mode 100644
index 000000000..e848753c1
--- /dev/null
+++ b/services/agents-api/internal/sandbox/e2b/configuration_test.go
@@ -0,0 +1,73 @@
+package e2b
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+)
+
+func TestConfigurationCodecSeparatesSecretsAndPreservesObservations(t *testing.T) {
+ adapter := ConfigurationAdapter{}
+ for _, metadata := range []string{`{}`, `{"template_build":{"status":null,"resources":{"cpus":2,"memory_mib":null,"root_disk_mib":null}}}`, `{"template_build":{"status":"ready","resources":{"cpus":2,"memory_mib":2048,"root_disk_mib":null}}}`} {
+ record := sandbox.ConfigurationRecord{Public: json.RawMessage(`{"template":"old-build","api_url":"https://api.e2b.app","domain":"e2b.app"}`), Metadata: json.RawMessage(metadata), Secret: []byte("private-fixture")}
+ restored, err := adapter.Decode(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Retained ownership is readable even when its old selector would fail new admission.
+ if _, err := adapter.Normalize(sandbox.Selection{Provider: "e2b", Configuration: restored}); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal("old selector admitted", err)
+ }
+ encoded, err := adapter.Encode(restored)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(encoded.Secret, record.Secret) {
+ t.Fatal("secret changed")
+ }
+ if len(encoded.Metadata) == 0 {
+ encoded.Metadata = json.RawMessage(`{}`)
+ }
+ if string(encoded.Metadata) != metadata {
+ t.Fatalf("observation changed: %s", encoded.Metadata)
+ }
+ values := []any{restored, struct{ Nested any }{restored}, sandbox.Selection{Configuration: restored}, encoded}
+ for _, v := range values {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(raw, record.Secret) {
+ t.Fatal("secret serialized")
+ }
+ }
+ if bytes.Contains([]byte(fmt.Sprintf("%+v", restored)), record.Secret) {
+ t.Fatal("secret in diagnostic")
+ }
+ }
+}
+
+func TestConfigurationInputRejectsUnknownAndPrivateFields(t *testing.T) {
+ adapter := ConfigurationAdapter{}
+ for _, tc := range []struct{ public, secret string }{
+ {`null`, ``}, {`[]`, ``}, {`{"api_key":"private"}`, ``}, {`{"Template":"x"}`, ``},
+ {`{"template_build":{}}`, ``}, {`{}`, `null`}, {`{}`, `{"api_key":null}`}, {`{}`, `{"api_key":""}`},
+ {`{}`, `{"API_KEY":"private"}`}, {`{}`, `{"api_key":"private","extra":true}`},
+ } {
+ if _, err := adapter.DecodeInput(json.RawMessage(tc.public), json.RawMessage(tc.secret)); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatalf("input accepted: %s", tc.public)
+ }
+ }
+ c, err := adapter.DecodeInput(json.RawMessage(`{"template":"test"}`), nil)
+ if err != nil || c.ReplacesCredential() {
+ t.Fatal("omission lost")
+ }
+ c, err = adapter.DecodeInput(json.RawMessage(`{"template":"test"}`), json.RawMessage(`{"api_key":"private"}`))
+ if err != nil || !c.ReplacesCredential() {
+ t.Fatal("explicit replacement lost")
+ }
+}
diff --git a/services/agents-api/internal/sandbox/e2b/contract_test.go b/services/agents-api/internal/sandbox/e2b/contract_test.go
index 88be73a8d..f4d55e827 100644
--- a/services/agents-api/internal/sandbox/e2b/contract_test.go
+++ b/services/agents-api/internal/sandbox/e2b/contract_test.go
@@ -3,11 +3,10 @@ package e2b
import (
"context"
"errors"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest"
"github.com/google/uuid"
+ "testing"
)
type contractCaller func(context.Context, Request) (Response, error)
diff --git a/services/agents-api/internal/sandbox/microsandbox/contract_test.go b/services/agents-api/internal/sandbox/microsandbox/contract_test.go
index 97f7a9ba9..3a11cb15d 100644
--- a/services/agents-api/internal/sandbox/microsandbox/contract_test.go
+++ b/services/agents-api/internal/sandbox/microsandbox/contract_test.go
@@ -3,11 +3,10 @@ package microsandbox
import (
"context"
"errors"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest"
"github.com/google/uuid"
+ "testing"
)
func TestProviderContract(t *testing.T) {
diff --git a/services/agents-api/internal/sandbox/microsandbox/process.go b/services/agents-api/internal/sandbox/microsandbox/process.go
index c81f2591c..7600b386c 100644
--- a/services/agents-api/internal/sandbox/microsandbox/process.go
+++ b/services/agents-api/internal/sandbox/microsandbox/process.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"io"
"os"
"os/exec"
@@ -12,8 +13,6 @@ import (
"strings"
"sync/atomic"
"syscall"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
)
// ProcessCaller never kills a mutating helper on a Core response timeout.
diff --git a/services/agents-api/internal/sandbox/microsandbox/process_test.go b/services/agents-api/internal/sandbox/microsandbox/process_test.go
index 1813b36b7..6d60cda93 100644
--- a/services/agents-api/internal/sandbox/microsandbox/process_test.go
+++ b/services/agents-api/internal/sandbox/microsandbox/process_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"os"
"os/exec"
"path/filepath"
@@ -12,8 +13,6 @@ import (
"syscall"
"testing"
"time"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
)
func TestMain(m *testing.M) {
diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/agents-api/internal/sandbox/node/agent.go
index 51b19a129..4883979c3 100644
--- a/services/agents-api/internal/sandbox/node/agent.go
+++ b/services/agents-api/internal/sandbox/node/agent.go
@@ -3,6 +3,7 @@ package node
import (
"context"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
"math/rand/v2"
"net/http"
"net/url"
@@ -11,8 +12,6 @@ import (
"sync/atomic"
"time"
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
-
"github.com/MiniMax-AI-Dev/parsar/internal/obs/log"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/gorilla/websocket"
diff --git a/services/agents-api/internal/sandbox/node/observations_test.go b/services/agents-api/internal/sandbox/node/observations_test.go
index a234fdd66..c41ab6a2a 100644
--- a/services/agents-api/internal/sandbox/node/observations_test.go
+++ b/services/agents-api/internal/sandbox/node/observations_test.go
@@ -4,14 +4,13 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"net/http/httptest"
"reflect"
"sync"
"testing"
"time"
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/google/uuid"
diff --git a/services/agents-api/internal/sandbox/node/operations_test.go b/services/agents-api/internal/sandbox/node/operations_test.go
index d59c915fc..67bbd6292 100644
--- a/services/agents-api/internal/sandbox/node/operations_test.go
+++ b/services/agents-api/internal/sandbox/node/operations_test.go
@@ -4,11 +4,10 @@ import (
"context"
"encoding/json"
"errors"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/google/uuid"
+ "testing"
)
func TestUnsupportedWireIsExplicitAndDoesNotInvokeProvider(t *testing.T) {
diff --git a/services/agents-api/internal/sandbox/node/recovery_test.go b/services/agents-api/internal/sandbox/node/recovery_test.go
index c96558f4d..aa7db298b 100644
--- a/services/agents-api/internal/sandbox/node/recovery_test.go
+++ b/services/agents-api/internal/sandbox/node/recovery_test.go
@@ -3,6 +3,9 @@ package node
import (
"context"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
"net/http"
"net/http/httptest"
"os"
@@ -11,10 +14,6 @@ import (
"sync"
"testing"
"time"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
- "github.com/google/uuid"
- "github.com/gorilla/websocket"
)
func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) {
diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/agents-api/internal/sandbox/node/wire.go
index 6ad5bb62a..53a34f2c2 100644
--- a/services/agents-api/internal/sandbox/node/wire.go
+++ b/services/agents-api/internal/sandbox/node/wire.go
@@ -7,11 +7,10 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"io"
"time"
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/google/uuid"
diff --git a/services/agents-api/internal/sandbox/operations.go b/services/agents-api/internal/sandbox/operations.go
index 73a54e61d..6c781cb62 100644
--- a/services/agents-api/internal/sandbox/operations.go
+++ b/services/agents-api/internal/sandbox/operations.go
@@ -3,10 +3,9 @@ package sandbox
import (
"errors"
"fmt"
- "reflect"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs"
+ "reflect"
)
// These existing interfaces are the canonical operation inventory. Declarations
diff --git a/services/agents-api/internal/sandbox/operations_test.go b/services/agents-api/internal/sandbox/operations_test.go
index 1af01d0a5..93cb1320a 100644
--- a/services/agents-api/internal/sandbox/operations_test.go
+++ b/services/agents-api/internal/sandbox/operations_test.go
@@ -3,14 +3,13 @@ package sandbox_test
import (
"context"
"errors"
- "reflect"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox"
+ "reflect"
+ "testing"
)
type changedDeclaration struct {
diff --git a/services/agents-api/internal/sandbox/providers/configuration.go b/services/agents-api/internal/sandbox/providers/configuration.go
index 909e532ee..288ae2459 100644
--- a/services/agents-api/internal/sandbox/providers/configuration.go
+++ b/services/agents-api/internal/sandbox/providers/configuration.go
@@ -59,6 +59,9 @@ func ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error)
return a.Configuration.ResolveChange(next, previous)
}
func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, error) {
+ if owner.Provider != candidate.Provider {
+ return owner, sandbox.ErrInvalid
+ }
a, e := Lookup(owner.Provider)
if e != nil {
return owner, e
diff --git a/services/agents-api/internal/sandbox/providers/configuration_flow_test.go b/services/agents-api/internal/sandbox/providers/configuration_flow_test.go
new file mode 100644
index 000000000..b74b2de3e
--- /dev/null
+++ b/services/agents-api/internal/sandbox/providers/configuration_flow_test.go
@@ -0,0 +1,155 @@
+package providers_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+)
+
+type regionalConfiguration struct {
+ Zone string `json:"zone"`
+}
+
+func (regionalConfiguration) HasCredential() bool { return false }
+func (regionalConfiguration) ReplacesCredential() bool { return false }
+
+type regionalCodec struct{}
+
+func (regionalCodec) Requirements() sandbox.ConfigurationRequirements {
+ return sandbox.ConfigurationRequirements{Credential: sandbox.NotRequired, PublicOrigin: sandbox.NotRequired, Discovery: providercontract.Support{State: providercontract.Unsupported, Reason: "node_configuration_has_no_catalog"}}
+}
+func (regionalCodec) WithCredential(sandbox.Configuration, sandbox.Configuration) (sandbox.Configuration, error) {
+ return nil, &providercontract.UnsupportedError{Operation: "WithCredential", Reason: "credentials_not_required"}
+}
+func (regionalCodec) DecodeInput(raw, secret json.RawMessage) (sandbox.Configuration, error) {
+ var c regionalConfiguration
+ if len(secret) > 0 || sandbox.DecodeConfigurationObject(raw, &c, "zone") != nil || c.Zone == "" {
+ return nil, sandbox.ErrInvalid
+ }
+ return c, nil
+}
+func (a regionalCodec) Decode(r sandbox.ConfigurationRecord) (sandbox.Configuration, error) {
+ if len(r.Secret) > 0 || sandbox.DecodeConfigurationObject(r.Metadata, &struct{}{}) != nil {
+ return nil, sandbox.ErrInvalid
+ }
+ return a.DecodeInput(r.Public, nil)
+}
+func (regionalCodec) Encode(c sandbox.Configuration) (sandbox.ConfigurationRecord, error) {
+ v, ok := c.(regionalConfiguration)
+ if !ok || v.Zone == "" {
+ return sandbox.ConfigurationRecord{}, sandbox.ErrInvalid
+ }
+ raw, _ := json.Marshal(v)
+ return sandbox.ConfigurationRecord{Public: raw, Metadata: json.RawMessage(`{}`)}, nil
+}
+func (a regionalCodec) Normalize(s sandbox.Selection) (sandbox.Selection, error) {
+ _, err := a.Encode(s.Configuration)
+ return s, err
+}
+func (a regionalCodec) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) {
+ return a.Normalize(next)
+}
+func (a regionalCodec) Equal(x, y sandbox.Configuration) (bool, error) {
+ _, err := a.Encode(x)
+ if err != nil {
+ return false, err
+ }
+ _, err = a.Encode(y)
+ return x == y, err
+}
+func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) {
+ return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"}
+}
+
+// A registered native configuration reaches the ordinary API and Store without
+// adding its fields or kind to either Core package.
+func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) {
+ dsn := os.Getenv("OAC_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("dedicated PostgreSQL required")
+ }
+ cfg, err := pgxpool.ParseConfig(dsn)
+ if err != nil || !strings.HasPrefix(cfg.ConnConfig.Database, "oac_") || !strings.HasSuffix(cfg.ConnConfig.Database, "_tests") {
+ t.Fatal("dedicated test database required")
+ }
+ admin, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer admin.Close()
+ name := "oac_provider_" + uuid.NewString()[:8] + "_tests"
+ quoted := pgx.Identifier{name}.Sanitize()
+ if _, err = admin.Exec(t.Context(), "CREATE DATABASE "+quoted); err != nil {
+ t.Fatal(err)
+ }
+ defer admin.Exec(context.Background(), "DROP DATABASE "+quoted+" WITH (FORCE)")
+ cfg.ConnConfig.Database = name
+ if err = migrations.Apply(t.Context(), cfg.ConnString()); err != nil {
+ t.Fatal(err)
+ }
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ kind := "regional-fixture"
+ adapter, err := providers.Lookup("docker")
+ if err != nil {
+ t.Fatal(err)
+ }
+ adapter.Configuration = regionalCodec{}
+ providers.RegisterFixture(t, kind, adapter)
+ s := store.New(pool)
+ lease, err := s.AcquireExecutionLease(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lease.Close(context.Background())
+ w := lease.Store()
+ installation := uuid.NewString()
+ if err = w.ClaimWebSandboxDeployment(t.Context(), installation); err != nil {
+ t.Fatal(err)
+ }
+ auth, err := api.NewDeploymentAuthenticator([]string{device.HashCredential("fixture-admin")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ h, err := api.NewHandler(s, nil, "codex", api.WithSandboxManager(s, auth), api.WithSandboxDeploymentSetup(func(ctx context.Context, in store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) {
+ return w.InitializeSandboxDeployment(ctx, installation, in)
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtime := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)}
+ body, _ := json.Marshal(map[string]any{"provider": kind, "expected_generation": 0, "resources": sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, "runtime": runtime, "configuration": map[string]string{"zone": "west"}})
+ request := httptest.NewRequest("POST", "/core/v1/sandbox/deployment", bytes.NewReader(body))
+ request.Header.Set("Authorization", "Bearer fixture-admin")
+ response := httptest.NewRecorder()
+ h.ServeHTTP(response, request)
+ if response.Code != 200 || !strings.Contains(response.Body.String(), `"zone":"west"`) {
+ t.Fatal(response.Code, response.Body.String())
+ }
+ reopened := store.New(pool)
+ saved, err := reopened.GetSandboxSetup(t.Context())
+ if err != nil || saved.Configuration.(regionalConfiguration).Zone != "west" {
+ t.Fatal("configuration did not roundtrip", err)
+ }
+ var raw []byte
+ if err = pool.QueryRow(t.Context(), "SELECT provider_config FROM runtime_deployment").Scan(&raw); err != nil || !strings.Contains(string(raw), `"zone": "west"`) {
+ t.Fatal("native fields not persisted", err)
+ }
+}
diff --git a/services/agents-api/internal/sandbox/providers/configuration_test.go b/services/agents-api/internal/sandbox/providers/configuration_test.go
new file mode 100644
index 000000000..6e09f4762
--- /dev/null
+++ b/services/agents-api/internal/sandbox/providers/configuration_test.go
@@ -0,0 +1,38 @@
+package providers
+
+import (
+ "encoding/json"
+ "errors"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "testing"
+)
+
+func TestNodeConfigurationExplicitUnsupportedAndStrictEmptyInput(t *testing.T) {
+ for _, kind := range []string{"docker", "microsandbox"} {
+ a, err := Lookup(kind)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, raw := range []string{`null`, `[]`, `{"secret":"private"}`, `{"template":"x"}`} {
+ if _, err := a.Configuration.DecodeInput(json.RawMessage(raw), nil); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal(kind, "accepted configuration", err)
+ }
+ }
+ for _, secret := range []string{`{}`, `null`, `{"api_key":"private"}`} {
+ if _, err := a.Configuration.DecodeInput(nil, json.RawMessage(secret)); !errors.Is(err, sandbox.ErrInvalid) {
+ t.Fatal(kind, "accepted credential", err)
+ }
+ }
+ discover, ok := a.Configuration.(sandbox.ConfigurationDiscoverer)
+ if !ok {
+ t.Fatal("missing explicit discovery implementation")
+ }
+ if _, err := discover.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}); !errors.Is(err, providercontract.ErrUnsupported) {
+ t.Fatal("discovery did not reject", err)
+ }
+ if _, err := a.Configuration.WithCredential(nil, nil); !errors.Is(err, providercontract.ErrUnsupported) {
+ t.Fatal("credential replacement did not reject", err)
+ }
+ }
+}
diff --git a/services/agents-api/internal/sandbox/providers/deployment_contract_test.go b/services/agents-api/internal/sandbox/providers/deployment_contract_test.go
index 4f9bc8e20..c016a356a 100644
--- a/services/agents-api/internal/sandbox/providers/deployment_contract_test.go
+++ b/services/agents-api/internal/sandbox/providers/deployment_contract_test.go
@@ -3,11 +3,10 @@ package providers
import (
"bytes"
"encoding/json"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"os"
"strings"
"testing"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
)
func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) {
diff --git a/services/agents-api/internal/sandbox/providers/export_test.go b/services/agents-api/internal/sandbox/providers/export_test.go
new file mode 100644
index 000000000..d9f223cb0
--- /dev/null
+++ b/services/agents-api/internal/sandbox/providers/export_test.go
@@ -0,0 +1,13 @@
+package providers
+
+import "testing"
+
+// RegisterFixture exists only in this package's test binary, never in Core.
+func RegisterFixture(t *testing.T, kind string, adapter Adapter) {
+ t.Helper()
+ if _, ok := adapters[kind]; ok {
+ t.Fatal("fixture replaces existing registration")
+ }
+ adapters[kind] = adapter
+ t.Cleanup(func() { delete(adapters, kind) })
+}
diff --git a/services/agents-api/internal/sandbox/providers/operations.go b/services/agents-api/internal/sandbox/providers/operations.go
index 330764e02..b6c619c5d 100644
--- a/services/agents-api/internal/sandbox/providers/operations.go
+++ b/services/agents-api/internal/sandbox/providers/operations.go
@@ -1,10 +1,9 @@
package providers
import (
- "reflect"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "reflect"
)
// ValidateBinding catches construction that disagrees with its registration.
diff --git a/services/agents-api/internal/sandbox/providers/operations_test.go b/services/agents-api/internal/sandbox/providers/operations_test.go
index 2bac7d8e5..ad305f48a 100644
--- a/services/agents-api/internal/sandbox/providers/operations_test.go
+++ b/services/agents-api/internal/sandbox/providers/operations_test.go
@@ -2,11 +2,10 @@ package providers
import (
"errors"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/providercontract"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
+ "testing"
)
func TestRegistrationRejectsMissingAndMismatchedDeclarations(t *testing.T) {
diff --git a/services/agents-api/internal/store/admin_session_archive.go b/services/agents-api/internal/store/admin_session_archive.go
index b9398f39e..3235c8bdc 100644
--- a/services/agents-api/internal/store/admin_session_archive.go
+++ b/services/agents-api/internal/store/admin_session_archive.go
@@ -3,9 +3,8 @@ package store
import (
"context"
"errors"
- "time"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit"
+ "time"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc"
"github.com/jackc/pgx/v5"
diff --git a/services/agents-api/internal/store/environment_initialization_test.go b/services/agents-api/internal/store/environment_initialization_test.go
index bec908f53..32d4af3ac 100644
--- a/services/agents-api/internal/store/environment_initialization_test.go
+++ b/services/agents-api/internal/store/environment_initialization_test.go
@@ -5,13 +5,6 @@ import (
"context"
"encoding/json"
"errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
@@ -19,6 +12,12 @@ import (
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/google/uuid"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
)
diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go
index a9e56d8a1..d12f0cfae 100644
--- a/services/agents-api/internal/store/environment_templates.go
+++ b/services/agents-api/internal/store/environment_templates.go
@@ -4,11 +4,10 @@ import (
"context"
"encoding/json"
"errors"
- "time"
- "unicode/utf8"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork"
"github.com/MiniMax-AI-Dev/parsar/internal/agentplugin"
+ "time"
+ "unicode/utf8"
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
diff --git a/services/agents-api/internal/store/environment_templates_test.go b/services/agents-api/internal/store/environment_templates_test.go
index 49f70f63a..2b0f6562f 100644
--- a/services/agents-api/internal/store/environment_templates_test.go
+++ b/services/agents-api/internal/store/environment_templates_test.go
@@ -2,10 +2,9 @@ package store
import (
"errors"
+ "github.com/google/uuid"
"sync"
"testing"
-
- "github.com/google/uuid"
)
func TestEnvironmentTemplatesDurabilityIsolationAndConcurrentUpdates(t *testing.T) {
diff --git a/services/agents-api/internal/store/export_test.go b/services/agents-api/internal/store/export_test.go
index 2ec16100b..08a6df914 100644
--- a/services/agents-api/internal/store/export_test.go
+++ b/services/agents-api/internal/store/export_test.go
@@ -5,12 +5,11 @@ import (
"context"
"encoding/json"
- "testing"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
+ "testing"
)
func NewTestStore(t *testing.T) (*Store, *pgxpool.Pool) { return testStore(t) }
diff --git a/services/agents-api/internal/store/function_item_events_test.go b/services/agents-api/internal/store/function_item_events_test.go
index 823d60f26..4566d655a 100644
--- a/services/agents-api/internal/store/function_item_events_test.go
+++ b/services/agents-api/internal/store/function_item_events_test.go
@@ -2,10 +2,9 @@ package store
import (
"encoding/json"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items"
"reflect"
"testing"
-
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items"
)
func TestFunctionResultEventsAreInputs(t *testing.T) {
diff --git a/services/agents-api/internal/store/message_input_helpers_test.go b/services/agents-api/internal/store/message_input_helpers_test.go
index effa058c5..4016ca1c7 100644
--- a/services/agents-api/internal/store/message_input_helpers_test.go
+++ b/services/agents-api/internal/store/message_input_helpers_test.go
@@ -1,9 +1,8 @@
package store_test
import (
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
+ "testing"
)
func inputTextForTest(t *testing.T, input proto.MessageInput) string {
diff --git a/services/agents-api/internal/store/provider_configuration_migration_test.go b/services/agents-api/internal/store/provider_configuration_migration_test.go
new file mode 100644
index 000000000..d5fcc83d7
--- /dev/null
+++ b/services/agents-api/internal/store/provider_configuration_migration_test.go
@@ -0,0 +1,81 @@
+package store
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/json"
+ "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/e2b"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/stdlib"
+ "github.com/pressly/goose/v3"
+ "os"
+ "testing"
+)
+
+func TestProviderConfigurationMigrationPreservesCiphertextAndRetainedOwnership(t *testing.T) {
+ s, w, view, input := webSpecificationFixture(t, "e2b")
+ tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString()))
+ archiveAllocation(t, w, tenant, session, view.InstallationID)
+ input.Configuration.(*e2b.DeploymentConfiguration).Template = "next:" + uuid.NewString()
+ if _, err := w.UpdateSandboxDeployment(SandboxResetTestContext(t.Context()), view.InstallationID, SandboxDeploymentUpdateRequest{SandboxDeploymentSetupRequest: input, ExpectedGeneration: 1}); err != nil {
+ t.Fatal(err)
+ }
+ db := sql.OpenDB(stdlib.GetConnector(*s.pool.Config().ConnConfig))
+ defer db.Close()
+ migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../migrations"), goose.WithTableName("agents_api_schema_version"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = migration.DownTo(t.Context(), 90); err != nil {
+ t.Fatal(err)
+ }
+ // A valid old record can have only part of its observation populated.
+ if _, err = db.ExecContext(t.Context(), "UPDATE runtime_deployment SET e2b_template_cpus=2"); err != nil {
+ t.Fatal(err)
+ }
+ var before, after []byte
+ var generation int64
+ if err = db.QueryRowContext(t.Context(), "SELECT e2b_credential,generation FROM runtime_deployment").Scan(&before, &generation); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = migration.Up(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.QueryRowContext(t.Context(), "SELECT provider_credential FROM runtime_deployment").Scan(&after); err != nil || !bytes.Equal(before, after) {
+ t.Fatal("ciphertext rewritten", err)
+ }
+ restored, err := s.GetSandboxSetup(t.Context())
+ if err != nil || restored.Generation != uint64(generation) || restored.Configuration.(*e2b.DeploymentConfiguration).APIKey != input.Configuration.(*e2b.DeploymentConfiguration).APIKey {
+ t.Fatal("ownership or credential changed", err)
+ }
+ public, err := s.GetRuntimeDeployment(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var metadata struct {
+ TemplateBuild struct {
+ Resources struct {
+ CPUs *int `json:"cpus"`
+ Memory *int `json:"memory_mib"`
+ } `json:"resources"`
+ } `json:"template_build"`
+ }
+ if err = json.Unmarshal(public.Metadata, &metadata); err != nil || metadata.TemplateBuild.Resources.CPUs == nil || *metadata.TemplateBuild.Resources.CPUs != 2 || metadata.TemplateBuild.Resources.Memory != nil {
+ t.Fatal("partial metadata lost", string(public.Metadata), err)
+ }
+ var retained int
+ if err = db.QueryRowContext(t.Context(), "SELECT count(*) FROM runtime_deployment_generations WHERE generation=1 AND provider_config->>'template'<>$1", input.Configuration.(*e2b.DeploymentConfiguration).Template).Scan(&retained); err != nil || retained != 1 {
+ t.Fatal("retained ownership lost", err)
+ }
+ if _, err = db.ExecContext(t.Context(), "UPDATE runtime_deployment_generations SET provider_config='{}'"); err == nil {
+ t.Fatal("migration removed immutability")
+ }
+ if _, err = migration.DownTo(t.Context(), 90); err != nil {
+ t.Fatal(err)
+ }
+ var cpu int
+ var memory *int
+ if err = db.QueryRowContext(t.Context(), "SELECT e2b_credential,e2b_template_cpus,e2b_template_memory_mib FROM runtime_deployment").Scan(&after, &cpu, &memory); err != nil || !bytes.Equal(before, after) || cpu != 2 || memory != nil {
+ t.Fatal("downgrade lost valid data", err)
+ }
+}
diff --git a/services/agents-api/internal/store/public_url.go b/services/agents-api/internal/store/public_url.go
index a4e97864b..1d92dbd31 100644
--- a/services/agents-api/internal/store/public_url.go
+++ b/services/agents-api/internal/store/public_url.go
@@ -5,9 +5,8 @@ import (
"errors"
)
-// ErrSandboxPublicURLUnreachable rejects E2B selections and new E2B Sessions
-// while the installation public URL is loopback: E2B sandboxes reach Core from
-// E2B's cloud.
+// ErrSandboxPublicURLUnreachable rejects selection and admission when the provider
+// requires a reachable public origin and the installation is loopback.
var ErrSandboxPublicURLUnreachable = errors.New("This sandbox provider needs a reachable HTTPS public URL before they can connect to Core.")
// SetPublicURL records OAC_PUBLIC_URL, validated by the caller. Core
diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go
index c52ad26bb..09089c09d 100644
--- a/services/agents-api/internal/store/runtime_initialization_test.go
+++ b/services/agents-api/internal/store/runtime_initialization_test.go
@@ -6,14 +6,13 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin"
"reflect"
"strings"
"sync"
"testing"
"time"
- "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto"
diff --git a/services/agents-api/internal/store/runtime_node_generations_test.go b/services/agents-api/internal/store/runtime_node_generations_test.go
index e48c4857f..378c580b6 100644
--- a/services/agents-api/internal/store/runtime_node_generations_test.go
+++ b/services/agents-api/internal/store/runtime_node_generations_test.go
@@ -3,11 +3,10 @@ package store
import (
"database/sql"
"errors"
- "os"
- "testing"
-
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
+ "os"
+ "testing"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
diff --git a/services/agents-api/internal/store/runtime_placements.go b/services/agents-api/internal/store/runtime_placements.go
index b7ab7e68d..d087456c5 100644
--- a/services/agents-api/internal/store/runtime_placements.go
+++ b/services/agents-api/internal/store/runtime_placements.go
@@ -21,16 +21,16 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp
if d.ResetClear.Valid {
return ErrSandboxResetAdmission
}
+ // A changed installation address cannot admit guests that require a public
+ // origin. Existing owned resources remain available for cleanup.
+ if d.ProviderKind != "" && providers.RequiresPublicOrigin(d.ProviderKind) && LoopbackOrigin(publicURL) {
+ return ErrSandboxPublicURLUnreachable
+ }
if d.Mode == "direct" {
if d.AdmissionPaused {
return ErrRuntimeNodeUnavailable
}
- // E2B guests reach Core over the internet. A selection saved before the
- // public URL became loopback admits nothing, while its existing sandboxes
- // stay reachable for cleanup through the loaded provider.
- if providers.RequiresPublicOrigin(d.ProviderKind) && LoopbackOrigin(publicURL) {
- return ErrSandboxPublicURLUnreachable
- }
+
return nil
}
if d.ProviderKind == "" {
diff --git a/services/agents-api/internal/store/sandbox_deployment_setup.go b/services/agents-api/internal/store/sandbox_deployment_setup.go
index 170b54d91..60b03922f 100644
--- a/services/agents-api/internal/store/sandbox_deployment_setup.go
+++ b/services/agents-api/internal/store/sandbox_deployment_setup.go
@@ -13,7 +13,6 @@ import (
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
"github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
)
var ErrSandboxDeploymentConflict = errors.New("sandbox deployment is already configured differently")
@@ -193,10 +192,3 @@ func runtimeDeploymentView(d sqlc.RuntimeDeployment, publicURL string) (RuntimeD
}
return result, nil
}
-
-func optionalInt32(value pgtype.Int4) *int32 {
- if !value.Valid {
- return nil
- }
- return &value.Int32
-}
diff --git a/services/agents-api/internal/store/sandbox_specification_test.go b/services/agents-api/internal/store/sandbox_specification_test.go
index 9e60e8cb6..7ac27da68 100644
--- a/services/agents-api/internal/store/sandbox_specification_test.go
+++ b/services/agents-api/internal/store/sandbox_specification_test.go
@@ -1,9 +1,8 @@
package store
import (
- "strings"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
+ "strings"
)
func SandboxDeploymentTestSpec(provider string) sandbox.DeploymentSpec {
diff --git a/services/agents-api/internal/store/session_model_execution_test.go b/services/agents-api/internal/store/session_model_execution_test.go
index 88d9fb916..67481c70b 100644
--- a/services/agents-api/internal/store/session_model_execution_test.go
+++ b/services/agents-api/internal/store/session_model_execution_test.go
@@ -4,11 +4,10 @@ import (
"bytes"
"encoding/json"
"errors"
- "testing"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto"
"github.com/google/uuid"
+ "testing"
)
func TestSessionModelExecutionEncryptedAndBound(t *testing.T) {
diff --git a/services/agents-api/internal/store/token_usage.go b/services/agents-api/internal/store/token_usage.go
index 3c053bae4..9c4414011 100644
--- a/services/agents-api/internal/store/token_usage.go
+++ b/services/agents-api/internal/store/token_usage.go
@@ -4,13 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
- "math"
- "strings"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc"
"github.com/jackc/pgx/v5/pgtype"
+ "math"
+ "strings"
)
// MeasuredSessionUsage returns Core-internal measured usage for Runtime
diff --git a/services/agents-api/internal/store/token_usage_integration_test.go b/services/agents-api/internal/store/token_usage_integration_test.go
index e7e68f411..3304a3e8f 100644
--- a/services/agents-api/internal/store/token_usage_integration_test.go
+++ b/services/agents-api/internal/store/token_usage_integration_test.go
@@ -5,12 +5,11 @@ import (
"encoding/json"
"errors"
"fmt"
- "testing"
-
v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
+ "testing"
)
func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) {
diff --git a/services/agents-api/tools/microsandbox-provider/command_test.go b/services/agents-api/tools/microsandbox-provider/command_test.go
index c1055ae9a..90bd28b18 100644
--- a/services/agents-api/tools/microsandbox-provider/command_test.go
+++ b/services/agents-api/tools/microsandbox-provider/command_test.go
@@ -5,12 +5,11 @@ package main
import (
"context"
"errors"
- "strings"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox"
sdk "github.com/superradcompany/microsandbox/sdk/go"
+ "strings"
+ "testing"
)
func TestCommandRequiresExitAndSuccessfulStdin(t *testing.T) {
diff --git a/services/agents-api/tools/microsandbox-provider/lock_test.go b/services/agents-api/tools/microsandbox-provider/lock_test.go
index 19e7da876..0a76639cc 100644
--- a/services/agents-api/tools/microsandbox-provider/lock_test.go
+++ b/services/agents-api/tools/microsandbox-provider/lock_test.go
@@ -5,12 +5,11 @@ package main
import (
"context"
"errors"
+ wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox"
"os"
"path/filepath"
"testing"
"time"
-
- wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox"
)
func TestAllocationLockSurvivesCallerDeadlineUntilExplicitSettlement(t *testing.T) {
diff --git a/services/agents-api/tools/microsandbox-provider/observed_test.go b/services/agents-api/tools/microsandbox-provider/observed_test.go
index 3acd087bd..5fdad8ff3 100644
--- a/services/agents-api/tools/microsandbox-provider/observed_test.go
+++ b/services/agents-api/tools/microsandbox-provider/observed_test.go
@@ -5,11 +5,10 @@ package main
import (
"encoding/json"
"errors"
- "testing"
-
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
wire "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/microsandbox"
sdk "github.com/superradcompany/microsandbox/sdk/go"
+ "testing"
)
func TestRestoredOwnershipUsesExactParentWithoutLabels(t *testing.T) {
From cad0e1301cf85577da8f98dc61a6e342ff373f0e Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 16:07:03 +0800
Subject: [PATCH 3/5] Preserve safe configuration diagnostics and complete test
fixtures
---
apps/web/e2e/nodes.spec.ts | 10 +++++-----
apps/web/e2e/sandbox-generation.spec.ts | 2 +-
apps/web/src/lib/locale-strings.ts | 2 +-
apps/web/src/lib/sandbox-labels.ts | 2 +-
packages/agents-client/src/sandbox-client.ts | 5 +++--
services/agents-api/internal/api/errors.go | 6 +++++-
.../sandbox/providers/configuration_flow_test.go | 13 +++++++++++--
7 files changed, 27 insertions(+), 13 deletions(-)
diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts
index e99095753..e89b35994 100644
--- a/apps/web/e2e/nodes.spec.ts
+++ b/apps/web/e2e/nodes.spec.ts
@@ -305,7 +305,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async
const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`,
microsandbox_ref: `oac-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) };
const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime };
- let deployment = { installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } },
+ let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } },
owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64),
suspension: null };
let submitted: Record | null = null;
@@ -327,7 +327,7 @@ test("edits only the saved backend, preserving a custom size and Runtime", async
expect(submitted).not.toHaveProperty("core_url");
});
-test("keeps the page usable when Core refuses a sandbox change, and shows Core's reason", async ({ page, request }) => {
+test("keeps the page usable when Core refuses a sandbox change, and shows a safe refusal", async ({ page, request }) => {
await openConsole(page, request, "system?id=sandbox", { sandbox: "none" });
await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 403, message: "This console is read-only." });
await page.getByRole("button", { name: "Own machines" }).click();
@@ -336,12 +336,12 @@ test("keeps the page usable when Core refuses a sandbox change, and shows Core's
const save = page.getByRole("button", { name: "Save configuration" });
await save.click();
// A clear refusal changed nothing: no "couldn't confirm" dialog, and the same page to try again.
- await expect(page.getByText("This console is read-only.")).toBeVisible();
+ await expect(page.getByText("Core rejected the sandbox configuration.")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
- // One code covers several reasons, so a conflict shows Core's own.
+ // Only fixed safe copy is shown for configuration errors.
await failNext(request, { method: "POST", path: "/sandbox/deployment", status: 409, code: "sandbox_deployment_conflict", message: "Another administrator changed the deployment; it is now at generation 2." });
await save.click();
- await expect(page.getByText("Another administrator changed the deployment; it is now at generation 2.")).toBeVisible();
+ await expect(page.getByText("The sandbox deployment cannot change in its current state.")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
await save.click();
const added = page.getByRole("dialog", { name: "Add node" });
diff --git a/apps/web/e2e/sandbox-generation.spec.ts b/apps/web/e2e/sandbox-generation.spec.ts
index d91028905..d4bc44e7f 100644
--- a/apps/web/e2e/sandbox-generation.spec.ts
+++ b/apps/web/e2e/sandbox-generation.spec.ts
@@ -4,7 +4,7 @@ import type { SandboxAllocation, SandboxDeployment, SandboxNode } from "@agents-
import { expectManagementBoundary, failNext, openConsole, setDeployment, setNode, writes } from "./console";
const deploymentPath = "/core/v1/sandbox/deployment";
-const templateDiscoveryPath = "/core/v1/sandbox/e2b/templates";
+const templateDiscoveryPath = "/core/v1/sandbox/providers/e2b/discovery";
const rollout = (page: Page) => page.getByRole("region", { name: "Configuration rollout", exact: true });
const fact = (scope: Locator, label: string) => scope.locator("dt").filter({ hasText: new RegExp(`^${label}`) }).locator("..").locator("dd");
async function inspectRollout(page: Page, values: Record) {
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts
index 578db2dd2..5388b2d50 100644
--- a/apps/web/src/lib/locale-strings.ts
+++ b/apps/web/src/lib/locale-strings.ts
@@ -262,7 +262,7 @@ export const chinese = {
"Sign in to the console again to access sandbox management.": "请重新登录控制台以访问沙箱管理。",
"Sandbox administration is not configured on this console.": "此控制台尚未配置沙箱管理权限。",
"Core rejected the sandbox change": "Core 拒绝了此次沙箱更改",
- "Core rejected the E2B configuration.": "Core 拒绝了这个 E2B 配置。",
+ "Core rejected the sandbox configuration.": "Core 拒绝了这个沙箱配置。",
"The console configuration could not be read. Refresh to try again.": "无法读取控制台配置。请刷新重试。",
"The sandbox request was rejected. Refresh to check the current state.": "沙箱请求被拒绝。请刷新并检查当前状态。",
"The sandbox service is unavailable. Refresh to check the current state.": "沙箱服务不可用。请刷新并检查当前状态。",
diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts
index 4fc5c1320..28d290ada 100644
--- a/apps/web/src/lib/sandbox-labels.ts
+++ b/apps/web/src/lib/sandbox-labels.ts
@@ -19,7 +19,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string {
let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again.";
if (error instanceof AgentCoreError) {
const refused = !sandboxWriteUncertain(error);
- if (error.code === "sandbox_configuration_unconfirmed") { if (refused) key = "Core rejected the E2B configuration."; }
+ if (error.code === "sandbox_configuration_unconfirmed") { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; }
else if (refused) {
if (error.message) return error.message;
key = "The sandbox request was rejected. Refresh to check the current state.";
diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts
index 9babc0a8c..72f56b5fa 100644
--- a/packages/agents-client/src/sandbox-client.ts
+++ b/packages/agents-client/src/sandbox-client.ts
@@ -363,6 +363,7 @@ export class SandboxAdminClient {
} catch (error) {
if (error instanceof AgentCoreError && [400, 409, 503].includes(error.status)) {
const messages: Record = {
+ invalid_sandbox_configuration: "Invalid sandbox provider configuration.",
sandbox_specification_mismatch: "The node specification differs from the deployment.",
sandbox_deployment_conflict: "The sandbox deployment cannot change in its current state.",
sandbox_generation_stale: "The sandbox configuration changed. Refresh before submitting again.",
@@ -378,10 +379,10 @@ export class SandboxAdminClient {
if (error.code && Object.hasOwn(messages, error.code)) {
// Credential-bearing errors expose fixed local copy and allowlisted
// numeric facts plus exact status/code/field matches only.
- const fields = error.code === "sandbox_generation_stale" ? ["current_generation"] : error.code === "sandbox_in_use" ? ["allocations", "pending"] : [];
+ const fields = error.code === "sandbox_generation_stale" ? ["current_generation"] : error.code === "sandbox_in_use" ? ["allocations", "pending"] : error.code === "invalid_sandbox_configuration" ? ["min", "max"] : [];
const details = Object.fromEntries(fields.filter(field => isNonnegativeInteger(error.details?.[field])).map(field => [field, Number(error.details![field])]));
const safeParam = error.status === 400
- ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : null
+ ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : error.code === "invalid_sandbox_configuration" && ["runtime", "resources.cpus", "resources.memory_mib", "resources.root_disk_mib", "resources.environment_disk_mib"].includes(error.param ?? "") ? error.param : null
: error.status === 409 && error.code === "sandbox_credential_ownership" ? "credential" : null;
const param = error.param === safeParam ? safeParam : null;
throw new AgentCoreError(messages[error.code]!, error.status, error.code, param, undefined, Object.keys(details).length ? details : undefined);
diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go
index 56be987a7..e3aca2954 100644
--- a/services/agents-api/internal/api/errors.go
+++ b/services/agents-api/internal/api/errors.go
@@ -124,7 +124,11 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound
writeError(w, status, "internal_error", "The operation could not be completed.")
return
}
- writeError(w, status, configuration.Code, configuration.Message, configuration.Param)
+ if configuration.Param == "" {
+ writeError(w, status, configuration.Code, configuration.Message)
+ } else {
+ writeError(w, status, configuration.Code, configuration.Message, configuration.Param)
+ }
case errors.As(err, &unsupported):
writeError(w, http.StatusBadRequest, "sandbox_operation_unsupported", "The selected sandbox provider does not support this operation.")
case errors.Is(err, sandbox.ErrInvalid):
diff --git a/services/agents-api/internal/sandbox/providers/configuration_flow_test.go b/services/agents-api/internal/sandbox/providers/configuration_flow_test.go
index b74b2de3e..8bf536f3f 100644
--- a/services/agents-api/internal/sandbox/providers/configuration_flow_test.go
+++ b/services/agents-api/internal/sandbox/providers/configuration_flow_test.go
@@ -3,6 +3,7 @@ package providers_test
import (
"bytes"
"context"
+ "database/sql"
"encoding/json"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api"
@@ -10,10 +11,11 @@ import (
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/providers"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
- "github.com/MiniMax-AI-Dev/parsar/services/agents-api/migrations"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
+ "github.com/jackc/pgx/v5/stdlib"
+ "github.com/pressly/goose/v3"
"net/http/httptest"
"os"
"strings"
@@ -98,7 +100,14 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) {
}
defer admin.Exec(context.Background(), "DROP DATABASE "+quoted+" WITH (FORCE)")
cfg.ConnConfig.Database = name
- if err = migrations.Apply(t.Context(), cfg.ConnString()); err != nil {
+ db := sql.OpenDB(stdlib.GetConnector(*cfg.ConnConfig))
+ migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../../migrations"), goose.WithTableName("agents_api_schema_version"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = migration.Up(t.Context())
+ db.Close()
+ if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
From 4a0310537a80fbf7c276c9be47439b45b2c38f37 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 16:11:11 +0800
Subject: [PATCH 4/5] Propagate provider requirement lookup errors before
admission
---
.../sandbox/providers/configuration.go | 40 +++++++++++++++----
.../sandbox/providers/configuration_test.go | 17 ++++++++
.../core/internal/store/runtime_placements.go | 11 ++++-
.../internal/store/sandbox_generations.go | 6 ++-
4 files changed, 64 insertions(+), 10 deletions(-)
diff --git a/services/core/internal/sandbox/providers/configuration.go b/services/core/internal/sandbox/providers/configuration.go
index 2c7598479..de92c14d8 100644
--- a/services/core/internal/sandbox/providers/configuration.go
+++ b/services/core/internal/sandbox/providers/configuration.go
@@ -36,13 +36,35 @@ func Equal(kind string, a, b sandbox.Configuration) (bool, error) {
}
return adapter.Configuration.Equal(a, b)
}
-func UsesCredential(kind string) bool {
- a, e := Lookup(kind)
- return e == nil && a.Configuration.Requirements().Credential == sandbox.Required
+func UsesCredential(kind string) (bool, error) {
+ a, err := Lookup(kind)
+ if err != nil {
+ return false, err
+ }
+ if a.Configuration == nil {
+ return false, providercontract.ErrContract
+ }
+ return required(a.Configuration.Requirements().Credential)
}
-func RequiresPublicOrigin(kind string) bool {
- a, e := Lookup(kind)
- return e == nil && a.Configuration.Requirements().PublicOrigin == sandbox.Required
+func RequiresPublicOrigin(kind string) (bool, error) {
+ a, err := Lookup(kind)
+ if err != nil {
+ return false, err
+ }
+ if a.Configuration == nil {
+ return false, providercontract.ErrContract
+ }
+ return required(a.Configuration.Requirements().PublicOrigin)
+}
+func required(value sandbox.Requirement) (bool, error) {
+ switch value {
+ case sandbox.Required:
+ return true, nil
+ case sandbox.NotRequired:
+ return false, nil
+ default:
+ return false, providercontract.ErrContract
+ }
}
func Normalize(s sandbox.Selection) (sandbox.Selection, error) {
a, e := Lookup(s.Provider)
@@ -66,7 +88,11 @@ func WithCredential(owner, candidate sandbox.Selection) (sandbox.Selection, erro
if e != nil {
return owner, e
}
- if a.Configuration.Requirements().Credential != sandbox.Required {
+ needsCredential, e := UsesCredential(owner.Provider)
+ if e != nil {
+ return owner, e
+ }
+ if !needsCredential {
return owner, &providercontract.UnsupportedError{Operation: "WithCredential", Reason: "credentials_not_required"}
}
owner.Configuration, e = a.Configuration.WithCredential(owner.Configuration, candidate.Configuration)
diff --git a/services/core/internal/sandbox/providers/configuration_test.go b/services/core/internal/sandbox/providers/configuration_test.go
index f813a651c..215938ded 100644
--- a/services/core/internal/sandbox/providers/configuration_test.go
+++ b/services/core/internal/sandbox/providers/configuration_test.go
@@ -36,3 +36,20 @@ func TestNodeConfigurationExplicitUnsupportedAndStrictEmptyInput(t *testing.T) {
}
}
}
+
+func TestConfigurationRequirementsDoNotTurnLookupFailuresIntoFalse(t *testing.T) {
+ for _, check := range []func(string) (bool, error){UsesCredential, RequiresPublicOrigin} {
+ if _, err := check("missing-configuration-provider"); err == nil {
+ t.Fatal("unknown provider treated as not required")
+ }
+ if yes, err := check("docker"); err != nil || yes {
+ t.Fatal("explicit not-required rejected", err)
+ }
+ if yes, err := check("e2b"); err != nil || !yes {
+ t.Fatal("explicit required lost", err)
+ }
+ }
+ if _, err := required(""); !errors.Is(err, providercontract.ErrContract) {
+ t.Fatal("missing requirement treated as false", err)
+ }
+}
diff --git a/services/core/internal/store/runtime_placements.go b/services/core/internal/store/runtime_placements.go
index 29d8ff01e..b3c4cb524 100644
--- a/services/core/internal/store/runtime_placements.go
+++ b/services/core/internal/store/runtime_placements.go
@@ -23,9 +23,16 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp
}
// A changed installation address cannot admit guests that require a public
// origin. Existing owned resources remain available for cleanup.
- if d.ProviderKind != "" && providers.RequiresPublicOrigin(d.ProviderKind) && LoopbackOrigin(publicURL) {
- return ErrSandboxPublicURLUnreachable
+ if d.ProviderKind != "" {
+ publicOrigin, err := providers.RequiresPublicOrigin(d.ProviderKind)
+ if err != nil {
+ return err
+ }
+ if publicOrigin && LoopbackOrigin(publicURL) {
+ return ErrSandboxPublicURLUnreachable
+ }
}
+
if d.Mode == "direct" {
if d.AdmissionPaused {
return ErrRuntimeNodeUnavailable
diff --git a/services/core/internal/store/sandbox_generations.go b/services/core/internal/store/sandbox_generations.go
index 77efcbafe..8107adedf 100644
--- a/services/core/internal/store/sandbox_generations.go
+++ b/services/core/internal/store/sandbox_generations.go
@@ -90,7 +90,11 @@ func (s *Store) GetSandboxAllocationSetup(ctx context.Context, ref sandbox.Refer
if err != nil {
return SandboxSetup{}, ErrSandboxDeploymentConflict
}
- if providers.UsesCredential(g.ProviderKind) {
+ needsCredential, err := providers.UsesCredential(g.ProviderKind)
+ if err != nil {
+ return SandboxSetup{}, err
+ }
+ if needsCredential {
composed, err := providers.WithCredential(sandbox.Selection{Provider: g.ProviderKind, Configuration: retained}, sandbox.Selection{Provider: d.ProviderKind, Configuration: result.Configuration})
if err != nil {
return SandboxSetup{}, ErrSandboxDeploymentConflict
From be2eaebfb62de41b5401f2867728d0bf4c643670 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 30 Sep 2026 16:18:51 +0800
Subject: [PATCH 5/5] Reject undeclared configuration requirements in server
setup
---
services/core/cmd/server/managed_generations.go | 6 +++++-
services/core/cmd/server/managed_setup.go | 4 ++--
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/services/core/cmd/server/managed_generations.go b/services/core/cmd/server/managed_generations.go
index aa4eb8ab8..27bce505f 100644
--- a/services/core/cmd/server/managed_generations.go
+++ b/services/core/cmd/server/managed_generations.go
@@ -112,6 +112,10 @@ func (p *observedGenerationRouter) Observe(ctx context.Context, t runtimeobs.Tar
}
func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeployment, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) {
+ usesCredential, err := providers.UsesCredential(setup.Provider)
+ if err != nil {
+ return execution.PreparedRuntimeDeployment{}, err
+ }
adapter, err := providers.Lookup(setup.Provider)
if err != nil {
return execution.PreparedRuntimeDeployment{}, err
@@ -131,7 +135,7 @@ func (s *managedSetup) routeGenerations(candidate execution.PreparedRuntimeDeplo
if err := sandbox.ValidateProvider(candidate.Config.Provider); err != nil {
return execution.PreparedRuntimeDeployment{}, err
}
- if adapter.Configuration.Requirements().Credential != sandbox.Required {
+ if !usesCredential {
return candidate, nil
}
candidate.FenceCredential = func(ctx context.Context) (func(), error) {
diff --git a/services/core/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go
index 28714ab06..f23661b0a 100644
--- a/services/core/cmd/server/managed_setup.go
+++ b/services/core/cmd/server/managed_setup.go
@@ -83,11 +83,11 @@ func (s *managedSetup) load(ctx context.Context) (*execution.RuntimeProvider, er
func (s *managedSetup) prepare(ctx context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) {
// Adapters declare whether their guests require a public Core origin.
- adapter, err := providers.Lookup(setup.Provider)
+ requiresPublicOrigin, err := providers.RequiresPublicOrigin(setup.Provider)
if err != nil {
return execution.PreparedRuntimeDeployment{}, err
}
- if adapter.Configuration.Requirements().PublicOrigin == sandbox.Required && store.LoopbackOrigin(s.publicURL) {
+ if requiresPublicOrigin && store.LoopbackOrigin(s.publicURL) {
return execution.PreparedRuntimeDeployment{}, store.ErrSandboxPublicURLUnreachable
}
candidate, err := s.configuration(setup)