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);