From 094c71f4758201c0553361114a81ef0a56e16790 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 26 Sep 2026 04:47:06 +0000 Subject: [PATCH] Validate sandbox node, allocation and deployment responses strictly SandboxAdminClient cast the node list, node detail, allocation list and deployment JSON without checking it. Project each response against Core's serialization (store.RuntimeNode, RuntimeNodeDetail, RuntimeNodeAllocation, RuntimeDeploymentView): an unknown or missing member or a wrong type rejects it with 502 invalid_admin_response, so no unlisted member (such as a reflected E2B key) passes through. A node diagnostic is absent or a code; an unknown code reads as provider_unavailable, as Core documents. --- contracts/agents-api/sandbox-deployment.md | 6 + .../agents-client/src/sandbox-client.test.ts | 120 ++++++++++++++-- packages/agents-client/src/sandbox-client.ts | 134 ++++++++++++++++-- 3 files changed, 239 insertions(+), 21 deletions(-) diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 6b9d4b177..bf356bfe0 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -184,6 +184,12 @@ and node connections; it is not a replacement for `expected_generation`. Treat `specification_digest` as the server-provided identity of the provider, resource limits and Runtime release. Enrollment echoes it unchanged. +The typed `SandboxAdminClient` checks the deployment, node list, node detail and +allocation responses against exactly these shapes. An unknown or missing member, +or a wrong type, rejects the whole response with a 502 `invalid_admin_response` +error. A node's `diagnostic` is absent or a code, never empty; the client reads +an unknown code as `provider_unavailable`. + ## Initialization and changes POST validates the candidate before persistence and creates no compute, Session diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index b7ac20b6c..94bf68a3b 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -4,6 +4,109 @@ import { SandboxAdminClient, type SandboxNode } from "./sandbox-client"; function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } +// Shapes as Core serializes them (store.RuntimeNode, RuntimeNodeDetail, RuntimeNodeAllocation, RuntimeDeploymentView). +const created = "2026-09-25T08:00:00.123456789Z"; +/** A ready node: Core omits `diagnostic`. */ +const node = { + id: "3b0c1f4e-8a2d-4c6b-9e7f-1a2b3c4d5e6f", name: "core-01", provider: "docker", online: true, provider_ready: true, + cpu_count: 16, available_memory_bytes: 8589934592, available_disk_bytes: 107374182400, running: 1, snapshots: 0, + last_seen_at: "2026-09-25T09:00:00Z", max_active: 2, max_retained: 2, active: 1, reserved: 0, retained: 1, cleanup_pending: 0, + created_at: created, core_url: "https://core.example", enrollment_id: "9d2e6b1a-4c3f-4e8d-a7b6-5c4d3e2f1a0b", +}; +/** Never heard from, enrolled before Core recorded enrollment IDs, with a fixed readiness code. */ +const unready = { + ...node, id: "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", online: false, provider_ready: false, diagnostic: "kvm_unavailable", + cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, last_seen_at: null, active: 0, retained: 0, enrollment_id: null, +}; +const detail = { + ...node, + host: { effective_cpu_cores: 3.5, cpu_utilization: 0.35, total_memory_bytes: 17179869184, available_memory_bytes: 8589934592, available_disk_bytes: 107374182400, observed_at: "2026-09-25T09:00:00Z" }, + history: { resolution_seconds: 60, points: [ + { start: "2026-09-25T08:58:00Z", cpu_utilization_max: 0.4, memory_used_bytes_max: 8589934592, available_disk_bytes_min: 107374182400 }, + { start: "2026-09-25T08:59:00Z", cpu_utilization_max: null, memory_used_bytes_max: null, available_disk_bytes_min: null }, + ] }, +}; +const unobserved = { + ...unready, + host: { effective_cpu_cores: null, cpu_utilization: null, total_memory_bytes: null, available_memory_bytes: null, available_disk_bytes: null, observed_at: null }, + history: { resolution_seconds: 900, points: [] }, +}; +const allocation = { + id: "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", node_id: node.id, tenant_id: "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e", + session_id: "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", environment_id: "3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a", + 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: "parsar-core-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) }; +const unconfigured = { installation_id: "", provider: "", core_url: "https://core.example", maintenance: false, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; +const docker = { + ...unconfigured, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", 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), +}; +const microsandbox = { + ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192, environment_disk_mib: 8192 }, runtime }, + suspension: { idle_seconds: 300, retention_seconds: 86400 }, +}; +/** An E2B selection saved before Core recorded its template build. */ +const e2bDeployment = { + ...docker, provider: "e2b", mode: "direct", specification: { resources: { cpus: 2, memory_mib: 2048 } }, + e2b: { template: "runtime:00000000-0000-0000-0000-000000000001", credential_configured: true, template_build: { status: null, resources: { cpus: null, memory_mib: null, root_disk_mib: null } } }, +}; +const reads: Record Promise> = { + nodes: (client) => client.listNodes(), + detail: (client) => client.retrieveNode(node.id, "1h"), + unobserved: (client) => client.retrieveNode(unready.id, "24h"), + allocations: (client) => client.listAllocations(node.id), + deployment: (client) => client.retrieveDeployment(), +}; +function read(name: string, body: unknown) { + return reads[name]!(new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response(body)) })); +} +const { enrollment_id: _enrollment, ...unenrolled } = node; +const { suspension: _suspension, ...unsuspended } = docker; +const { specification_digest: _digest, ...undigested } = docker; +const { compute_phase_changed_at: _changed, ...unphased } = allocation; + +describe("strict sandbox administration projections", () => { + it.each([ + ["nodes", "ready and unready", { data: [node, unready] }], ["nodes", "empty", { data: [] }], + ["detail", "observed", detail], ["unobserved", "never observed", unobserved], + ["allocations", "known and unknown phase times", { data: [allocation, { ...allocation, compute_phase: "suspended", compute_phase_changed_at: created, diagnostic: "node_unavailable" }] }], + ["deployment", "unconfigured", unconfigured], ["deployment", "Docker", docker], ["deployment", "microsandbox", microsandbox], ["deployment", "E2B", e2bDeployment], + ])("accepts %s as Core serializes it: %s", async (name, _, body) => { + expect(await read(name, body)).toEqual(body); + }); + + it.each([ + ["nodes", "an unknown member", { data: [{ ...node, credential: "node-secret" }] }], + ["nodes", "a missing enrollment_id", { data: [unenrolled] }], + ["nodes", "an empty diagnostic", { data: [{ ...node, diagnostic: "" }] }], + ["nodes", "a string count", { data: [{ ...node, cpu_count: "16" }] }], + ["nodes", "a numeric timestamp", { data: [{ ...node, last_seen_at: 0 }] }], + ["nodes", "a null list", { data: null }], + ["detail", "a missing host", { ...node, history: detail.history }], + ["detail", "an unknown host member", { ...detail, host: { ...detail.host, hostname: "core-01" } }], + ["detail", "a string history point", { ...detail, history: { resolution_seconds: 60, points: [{ ...detail.history.points[0], cpu_utilization_max: "0.4" }] } }], + ["detail", "another node", { ...detail, id: unready.id }], + ["allocations", "a missing compute_phase_changed_at", { data: [unphased] }], + ["allocations", "an unknown diagnostic", { data: [{ ...allocation, diagnostic: "raw provider text" }] }], + ["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", "E2B without its e2b member", { ...docker, provider: "e2b", mode: "direct" }], + ["deployment", "an unknown mode", { ...docker, mode: "hybrid" }], + ])("rejects %s with %s", async (name, _, body) => { + await expect(read(name, body)).rejects.toMatchObject({ status: 502, code: "invalid_admin_response" }); + }); + + it("never passes a reflected E2B key through", async () => { + const reflected = { ...e2bDeployment, e2b: { ...e2bDeployment.e2b, 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"); + }); +}); + describe("Core sandbox credential boundaries", () => { it("defaults to /core/v1/sandbox and is not a /v1 client", async () => { const fetch = vi.fn().mockImplementation(async () => response({ data: [] })); @@ -24,16 +127,16 @@ describe("Core sandbox credential boundaries", () => { expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("Authorization")).toBe(false); expect(fetch.mock.calls[0]?.[1]?.signal).toBe(controller.signal); }); - it("returns the node's fixed readiness diagnostic unchanged", async () => { - const fetch = vi.fn().mockResolvedValue(response({ data: [{ id: "node", provider_ready: false, diagnostic: "kvm_unavailable" }] })); + it("returns the node's fixed readiness diagnostic unchanged and reads an unknown code as provider_unavailable", async () => { + const fetch = vi.fn().mockResolvedValue(response({ data: [unready, { ...unready, diagnostic: "future_code" }, node] })); const { data } = await new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch }).listNodes(); - expect(data[0]?.diagnostic).toBe("kvm_unavailable"); + expect(data.map((entry) => entry.diagnostic)).toEqual(["kvm_unavailable", "provider_unavailable", undefined]); expectTypeOf().toEqualTypeOf(); }); it("requires each node's enrollment ID: a string, or null for nodes enrolled before Core recorded it", async () => { - const fetch = vi.fn().mockResolvedValue(response({ data: [{ id: "enrolled", enrollment_id: "3b0c1f4e-8a2d-4c6b-9e7f-1a2b3c4d5e6f" }, { id: "legacy", enrollment_id: null }] })); + const fetch = vi.fn().mockResolvedValue(response({ data: [node, unready] })); const { data } = await new SandboxAdminClient({ fetch }).listNodes(); - expect(data.map((node) => node.enrollment_id)).toEqual(["3b0c1f4e-8a2d-4c6b-9e7f-1a2b3c4d5e6f", null]); + expect(data.map((entry) => entry.enrollment_id)).toEqual([node.enrollment_id, null]); expectTypeOf().toEqualTypeOf(); expectTypeOf<{ enrollment_id: string }>().toExtend>(); expectTypeOf<{ enrollment_id: null }>().toExtend>(); @@ -47,7 +150,7 @@ describe("Core sandbox credential boundaries", () => { }); it("initializes using only the explicit provider with cancellation and admin credentials", async () => { - const deployment = { installation_id: "installation", provider: "docker", core_url: "https://core.example", maintenance: false, owner_epoch: 1 }; + const deployment = docker; const fetch = vi.fn().mockResolvedValue(response(deployment)); const admin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", token: "admin-only", fetch }); const controller = new AbortController(); @@ -81,7 +184,7 @@ describe("Core sandbox credential boundaries", () => { expect(fetch).toHaveBeenCalledTimes(1); }); it("uses the explicit admin credential and admin routes without a project beta header", async () => { - const fetch = vi.fn().mockImplementation(async () => response({ data: [] })); + const fetch = vi.fn().mockImplementation(async (url) => response(String(url).endsWith("/deployment") ? docker : { data: [] })); const admin = new SandboxAdminClient({ baseUrl: "https://core.example/core/v1/sandbox", token: "admin-only", fetch }); await admin.retrieveDeployment(); await admin.listNodes(); await admin.listAllocations("node/a"); await admin.createEnrollment(); await admin.removeNode("node/a"); expect(fetch.mock.calls.map(([url]) => url)).toEqual([ @@ -108,8 +211,7 @@ describe("hosted provider configuration", () => { const e2b = { api_key: "test-only-secret", template: "runtime:00000000-0000-0000-0000-000000000001" }; it("writes E2B configuration and generation without beta headers or browser credentials", async () => { const deployment = { - generation: 2, mode: "direct", resources: { allocations: 0, pending: 0 }, suspension: null, - specification: { resources: { cpus: 2, memory_mib: 2048 } }, + ...e2bDeployment, generation: 2, resources: { allocations: 0, pending: 0 }, e2b: { template: e2b.template, credential_configured: true, template_build: { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 24063 } } }, }; const fetch = vi.fn().mockImplementation(async () => response(deployment)); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 567e6053a..99fdca795 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -1,9 +1,10 @@ import { AgentCoreError } from "./client"; import { CoreRequester, type CoreClientOptions } from "./core-request"; +import { hasOwn, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { ReadOptions } from "./types"; export type SandboxDiagnostic = "" | "node_unavailable" | "resource_missing" | "compute_unconfirmed" | "ownership_mismatch" | "provider_unavailable"; -/** Fixed reason a node's provider is not ready. Core omits the field while the provider is ready, so read it as falsy (undefined) then. Treat an unknown future value as provider_unavailable. */ +/** Fixed reason a node's provider is not ready. Core omits the field while the provider is ready, so read it as falsy (undefined) then. The client reads an unknown future value as provider_unavailable. */ export type SandboxNodeDiagnostic = | "" | "provider_unavailable" @@ -132,6 +133,114 @@ function invalidSandboxResponse(): never { throw new AgentCoreError("Core returned an invalid sandbox administration response.", 502, "invalid_admin_response"); } +/** + * The object has every required member, optional ones only where listed, and nothing else. The projections below + * follow Core's store.RuntimeDeploymentView, RuntimeNode, RuntimeNodeDetail and RuntimeNodeAllocation serialization. + */ +function members(value: unknown, required: readonly string[], optional: readonly string[] = []): Record { + if (!isRecord(value) || !required.every((field) => hasOwn(value, field)) || !onlyFields(value, new Set([...required, ...optional]))) return invalidSandboxResponse(); + return value; +} +function valid(condition: boolean): void { + if (!condition) invalidSandboxResponse(); +} +const timestamp = (value: unknown) => typeof value === "string" && Number.isFinite(Date.parse(value)); +const measure = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0; +const nullable = (test: (value: unknown) => boolean) => (value: unknown) => value === null || test(value); +const strings = (value: Record, fields: readonly string[]) => fields.every((field) => typeof value[field] === "string"); + +const providers = new Set(["", "docker", "microsandbox", "e2b"]); +const modes = new Set(["", "nodes", "direct"]); +const releaseFields = ["source_commit", "image_id", "image_manifest_digest", "microsandbox_ref", "runtime_sha256", "firmware_sha256"]; +function projectSpecification(value: unknown): SandboxSpecification { + const specification = members(value, ["resources"], ["runtime"]); + const resources = members(specification.resources, ["cpus", "memory_mib"], ["root_disk_mib", "environment_disk_mib"]); + valid(Object.values(resources).every(isNonnegativeInteger)); + if (!hasOwn(specification, "runtime")) return { resources: { ...resources } as unknown as SandboxResources }; + const runtime = members(specification.runtime, releaseFields); + 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"]); + const build = members(e2b.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" && (build.status === null || typeof build.status === "string") && + Object.values(resources).every(nullable(isNonnegativeInteger))); + return { + template: e2b.template as string, credential_configured: e2b.credential_configured as boolean, + template_build: { status: build.status as string | null, resources: { ...resources } as SandboxE2BTemplateBuild["resources"] }, + }; +} +/** `specification` and `specification_digest` appear together once configured; `e2b` appears exactly for E2B. */ +function projectDeployment(value: unknown): SandboxDeployment { + const e2b = isRecord(value) && value.provider === "e2b"; + const fields = ["installation_id", "provider", "core_url", "maintenance", "owner_epoch", "generation", "mode", "resources", "suspension"]; + const deployment = members(value, e2b ? [...fields, "e2b"] : fields, ["specification", "specification_digest"]); + 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"); + valid(strings(deployment, ["installation_id", "core_url"]) && providers.has(deployment.provider as string) && modes.has(deployment.mode as string) && + typeof deployment.maintenance === "boolean" && [deployment.owner_epoch, deployment.generation, resources.allocations, resources.pending].every(isNonnegativeInteger) && + (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && + configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); + return { + ...deployment, resources: { ...resources } as SandboxDeployment["resources"], suspension: suspension && { ...suspension } as SandboxDeployment["suspension"], + ...(configured ? { specification: projectSpecification(deployment.specification) } : {}), + ...(e2b ? { e2b: projectE2B(deployment.e2b) } : {}), + } as unknown as SandboxDeployment; +} + +const nodeFields = ["id", "name", "provider", "online", "provider_ready", "cpu_count", "available_memory_bytes", "available_disk_bytes", "running", "snapshots", + "last_seen_at", "max_active", "max_retained", "active", "reserved", "retained", "cleanup_pending", "created_at", "core_url", "enrollment_id"]; +const nodeDiagnostics = new Set(["provider_unavailable", "docker_unavailable", "docker_limits_unsupported", "runtime_image_unavailable", "kvm_unavailable", "microsandbox_artifacts_unavailable", "capacity_insufficient"]); +/** Core omits an empty `diagnostic`, so a present one is a code; an unknown code reads as provider_unavailable. */ +function projectNode(node: Record): SandboxNode { + const { diagnostic, ...fields } = node; + valid(strings(node, ["id", "name", "provider", "core_url"]) && typeof node.online === "boolean" && typeof node.provider_ready === "boolean" && + [node.cpu_count, node.available_memory_bytes, node.available_disk_bytes].every(nullable(isNonnegativeInteger)) && + [node.running, node.snapshots, node.max_active, node.max_retained, node.active, node.reserved, node.retained, node.cleanup_pending].every(isNonnegativeInteger) && + nullable(timestamp)(node.last_seen_at) && timestamp(node.created_at) && (node.enrollment_id === null || typeof node.enrollment_id === "string") && + (diagnostic === undefined || (typeof diagnostic === "string" && diagnostic !== ""))); + if (diagnostic === undefined) return { ...fields } as unknown as SandboxNode; + return { ...fields, diagnostic: nodeDiagnostics.has(diagnostic as string) ? diagnostic : "provider_unavailable" } as unknown as SandboxNode; +} +function projectNodeList(value: unknown): { data: SandboxNode[] } { + const list = members(value, ["data"]); + valid(Array.isArray(list.data)); + return { data: (list.data as unknown[]).map((entry) => projectNode(members(entry, nodeFields, ["diagnostic"]))) }; +} +/** A never-observed host is all null; the history lists every bucket of the range. */ +function projectNodeDetail(value: unknown, nodeId: string): SandboxNodeDetail { + const { host, history, ...fields } = members(value, [...nodeFields, "host", "history"], ["diagnostic"]); + const node = projectNode(fields); + const observed = members(host, ["effective_cpu_cores", "cpu_utilization", "total_memory_bytes", "available_memory_bytes", "available_disk_bytes", "observed_at"]); + const buckets = members(history, ["resolution_seconds", "points"]); + valid(sameResourceId(node.id, nodeId) && [observed.effective_cpu_cores, observed.cpu_utilization].every(nullable(measure)) && + [observed.total_memory_bytes, observed.available_memory_bytes, observed.available_disk_bytes].every(nullable(isNonnegativeInteger)) && + nullable(timestamp)(observed.observed_at) && isNonnegativeInteger(buckets.resolution_seconds) && buckets.resolution_seconds > 0 && Array.isArray(buckets.points)); + const points = (buckets.points as unknown[]).map((entry) => { + const point = members(entry, ["start", "cpu_utilization_max", "memory_used_bytes_max", "available_disk_bytes_min"]); + valid(timestamp(point.start) && nullable(measure)(point.cpu_utilization_max) && [point.memory_used_bytes_max, point.available_disk_bytes_min].every(nullable(isNonnegativeInteger))); + return { ...point } as unknown as SandboxNodeHostPoint; + }); + return { ...node, host: { ...observed } as unknown as SandboxNodeHost, history: { resolution_seconds: buckets.resolution_seconds as number, points } }; +} + +const allocationFields = ["id", "node_id", "tenant_id", "session_id", "environment_id", "state", "compute_phase", "initialization", "diagnostic"]; +const allocationDiagnostics = new Set(["", "node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable"]); +function projectAllocations(value: unknown, nodeId: string): { data: SandboxAllocation[] } { + const list = members(value, ["data"]); + valid(Array.isArray(list.data)); + return { data: (list.data as unknown[]).map((entry) => { + const allocation = members(entry, [...allocationFields, "compute_phase_changed_at", "created_at"]); + valid(strings(allocation, allocationFields) && sameResourceId(allocation.node_id as string, nodeId) && allocationDiagnostics.has(allocation.diagnostic as string) && + nullable(timestamp)(allocation.compute_phase_changed_at) && timestamp(allocation.created_at)); + return { ...allocation } as unknown as SandboxAllocation; + }) }; +} + /** Deployment administration under `/core/v1/sandbox`, through an authenticated console or an explicit Core key. */ export class SandboxAdminClient { readonly #core: CoreRequester; @@ -143,8 +252,8 @@ export class SandboxAdminClient { #json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { return this.#core.json(path, options, method, body) as Promise; } - retrieveDeployment(options?: ReadOptions): Promise { - return this.#json("/deployment", options); + async retrieveDeployment(options?: ReadOptions): Promise { + return projectDeployment(await this.#core.json("/deployment", options)); } initializeDeployment(input: InitializeSandboxDeployment, options?: ReadOptions): Promise { return this.#writeDeployment("POST", input, options); @@ -152,12 +261,13 @@ export class SandboxAdminClient { updateDeployment(input: UpdateSandboxDeployment, options?: ReadOptions): Promise { return this.#writeDeployment("PUT", input, options); } - setMaintenance(input: SetSandboxMaintenance, options?: ReadOptions): Promise { - return this.#json("/deployment/maintenance", options, "PATCH", input); + async setMaintenance(input: SetSandboxMaintenance, options?: ReadOptions): Promise { + return projectDeployment(await this.#core.json("/deployment/maintenance", options, "PATCH", input)); } async #writeDeployment(method: "POST" | "PUT", input: InitializeSandboxDeployment | UpdateSandboxDeployment, options?: ReadOptions): Promise { try { - return await this.#json("/deployment", options, method, input); + // As with an unparsable body, an E2B write with an invalid response is unconfirmed. + return projectDeployment(await this.#core.json("/deployment", options, method, input)); } catch (error) { if (!input.e2b) throw error; // The public-URL rejection is safe to show unless it somehow reflects the key. @@ -171,14 +281,14 @@ export class SandboxAdminClient { throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, "sandbox_configuration_unconfirmed"); } } - listNodes(options?: ReadOptions): Promise<{ data: SandboxNode[] }> { - return this.#json("/nodes", options); + async listNodes(options?: ReadOptions): Promise<{ data: SandboxNode[] }> { + return projectNodeList(await this.#core.json("/nodes", options)); } - retrieveNode(nodeId: string, range: SandboxNodeHistoryRange, options?: ReadOptions): Promise { - return this.#json(`/nodes/${encodeURIComponent(nodeId)}?range=${range}`, options); + async retrieveNode(nodeId: string, range: SandboxNodeHistoryRange, options?: ReadOptions): Promise { + return projectNodeDetail(await this.#core.json(`/nodes/${encodeURIComponent(nodeId)}?range=${range}`, options), nodeId); } - listAllocations(nodeId: string, options?: ReadOptions): Promise<{ data: SandboxAllocation[] }> { - return this.#json(`/nodes/${encodeURIComponent(nodeId)}/allocations`, options); + async listAllocations(nodeId: string, options?: ReadOptions): Promise<{ data: SandboxAllocation[] }> { + return projectAllocations(await this.#core.json(`/nodes/${encodeURIComponent(nodeId)}/allocations`, options), nodeId); } createEnrollment(options?: ReadOptions, capacity: { max_active?: number; max_retained?: number } = {}): Promise { return this.#json("/enrollment-tokens", options, "POST", capacity);