diff --git a/.changeset/cli-sandbox-sidecars.md b/.changeset/cli-sandbox-sidecars.md new file mode 100644 index 0000000000..32f7bff495 --- /dev/null +++ b/.changeset/cli-sandbox-sidecars.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': minor +--- + +`e2b sandbox list` gains a `SIDECARS` column (`entry:state`, comma-separated) and `e2b sandbox info` prints a table of the sandbox's sidecars with their entry, version, role, class, state, name, address and ports. diff --git a/.changeset/sandbox-sidecars.md b/.changeset/sandbox-sidecars.md new file mode 100644 index 0000000000..c0ad6c7e1b --- /dev/null +++ b/.changeset/sandbox-sidecars.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add `sidecars` to sandbox creation: companion microVMs from the E2B sidecar catalog that run next to the sandbox inside its private network and are reached by the name `{entry}.sidecar.e2b.local`. Each entry names a catalog item — `iron-proxy`, a proxy that swaps a placeholder for the real secret value on egress so the secret never enters the sandbox; `valkey`, a Valkey (Redis-compatible) cache; `sqlite`, libsql-server over HTTP at `http://sqlite.sidecar.e2b.local:8080`; `iroh`, a peer-to-peer tunnel with `publish`/`connect` pipes whose tickets are read from `http://iroh.sidecar.e2b.local:8080/tickets.json` once `status` is `ready` — with an optional `version`, entry-specific `config`, and `secrets` slots holding `${e2b.secrets.}` references. A sidecar follows the sandbox's lifecycle: it is paused and snapshotted with the sandbox, comes back exactly as it was on resume (its data included), is forked with it (a forked sandbox's `iroh` sidecar starts with a fresh peer identity), and is terminated with it; a sidecar that crashes is restarted once from its clean image and then reported `failed` while the sandbox keeps running. Sandbox info and list return the attached sidecars with their role, class, state, name, address and ports. Sidecar rejections surface as `InvalidArgumentError` / `InvalidArgumentException` with the API's `sidecar_*` code in the message; a sidecar that fails to start keeps its entry name in the error. Requires the team's `sandbox-sidecars` feature. diff --git a/packages/cli/src/commands/sandbox/info.ts b/packages/cli/src/commands/sandbox/info.ts index c15cb08209..c86eb74948 100644 --- a/packages/cli/src/commands/sandbox/info.ts +++ b/packages/cli/src/commands/sandbox/info.ts @@ -1,8 +1,9 @@ import * as commander from 'commander' -import { NotFoundError, Sandbox } from 'e2b' +import { NotFoundError, Sandbox, SidecarInfo } from 'e2b' import { ensureAPIKey } from 'src/api' import { asBold } from 'src/utils/format' +import { formatTable } from 'src/utils/table' const fieldLabels: Partial> = { sandboxId: 'Sandbox ID', @@ -17,6 +18,7 @@ const fieldLabels: Partial> = { allowInternetAccess: 'Internet access', lifecycle: 'Lifecycle', network: 'Network', + sidecars: 'Sidecars', sandboxDomain: 'Sandbox domain', metadata: 'Metadata', } @@ -34,6 +36,7 @@ const fieldOrder = [ 'allowInternetAccess', 'lifecycle', 'network', + 'sidecars', 'sandboxDomain', 'metadata', ] @@ -71,7 +74,7 @@ export const infoCommand = new commander.Command('info') } }) -function renderPrettyInfo(info: Record) { +export function renderPrettyInfo(info: Record) { console.log( `\nSandbox info for ${asBold(String(info.sandboxId ?? 'unknown'))}:` ) @@ -87,8 +90,15 @@ function renderPrettyInfo(info: Record) { continue } + if (key === 'sidecars' && Array.isArray(value) && value.length === 0) { + continue + } + const label = fieldLabels[key] ?? key - const formattedValue = formatValue(value) + const formattedValue = + key === 'sidecars' && Array.isArray(value) + ? formatSidecarTable(value).join('\n') + : formatValue(value) if (formattedValue.includes('\n')) { const indentedValue = formattedValue @@ -105,6 +115,32 @@ function renderPrettyInfo(info: Record) { process.stdout.write('\n') } +export function formatSidecarTable(sidecars: SidecarInfo[]): string[] { + return formatTable(sidecars, [ + { header: 'Entry', value: (sidecar) => sidecar.entry }, + { header: 'Version', value: (sidecar) => sidecar.version }, + { header: 'Role', value: (sidecar) => sidecar.role }, + { header: 'Class', value: (sidecar) => sidecar.class }, + { header: 'State', value: (sidecar) => sidecar.state }, + { header: 'Name', value: (sidecar) => sidecar.name }, + { header: 'Address', value: (sidecar) => sidecar.address }, + { header: 'Ports', value: (sidecar) => sidecar.ports?.join(',') }, + { + header: 'Last error', + value: (sidecar) => truncate(sidecar.lastError, LAST_ERROR_WIDTH), + }, + ]) +} + +const LAST_ERROR_WIDTH = 60 + +function truncate(value: string | undefined, width: number) { + if (value === undefined || value.length <= width) { + return value + } + return `${value.slice(0, width - 1)}…` +} + function formatValue(value: unknown): string { if (value instanceof Date) { return value.toLocaleString() diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index e877970d5c..a426e993a0 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -1,5 +1,11 @@ import * as commander from 'commander' -import { components, Sandbox, SandboxInfo, SandboxListOrder } from 'e2b' +import { + components, + Sandbox, + SandboxInfo, + SandboxListOrder, + SidecarInfo, +} from 'e2b' import { ensureAPIKey } from 'src/api' import { renderTable } from 'src/utils/table' @@ -113,9 +119,16 @@ export function buildTableRows( endAt: new Date(sandbox.endAt).toLocaleString(), state: sandbox.state.charAt(0).toUpperCase() + sandbox.state.slice(1), // capitalize metadata: JSON.stringify(sandbox.metadata), + sidecars: formatSidecars(sandbox.sidecars), })) } +export function formatSidecars(sidecars: SidecarInfo[] | undefined) { + return (sidecars ?? []) + .map((sidecar) => `${sidecar.entry}:${sidecar.state}`) + .join(',') +} + function renderSandboxTable( sandboxes: SandboxInfo[], order?: SandboxListOrder @@ -135,6 +148,7 @@ function renderSandboxTable( { header: 'vCPUs', value: (row) => String(row.cpuCount) }, { header: 'RAM MiB', value: (row) => String(row.memoryMB) }, { header: 'Envd version', value: (row) => row.envdVersion }, + { header: 'Sidecars', value: (row) => row.sidecars }, { header: 'Metadata', value: (row) => row.metadata }, ]) } diff --git a/packages/cli/src/utils/table.ts b/packages/cli/src/utils/table.ts index c1cfff847b..d2ac03fb49 100644 --- a/packages/cli/src/utils/table.ts +++ b/packages/cli/src/utils/table.ts @@ -27,6 +27,16 @@ export interface Column { * ``` */ export function renderTable(items: T[], columns: Column[]) { + for (const line of formatTable(items, columns)) { + console.log(line) + } +} + +/** + * Formats `items` as the lines {@link renderTable} prints, for callers that + * embed the table in other output. + */ +export function formatTable(items: T[], columns: Column[]): string[] { const headers = columns.map((column) => column.header.toUpperCase()) const rows = items.map((item) => columns.map((column) => column.value(item) ?? '') @@ -36,16 +46,14 @@ export function renderTable(items: T[], columns: Column[]) { rows.reduce((max, row) => Math.max(max, wcswidth(row[i])), wcswidth(header)) ) - for (const line of [headers, ...rows]) { - console.log( - line - .map((cell, i) => - i === line.length - 1 - ? cell - : cell + ' '.repeat(widths[i] + COLUMN_PADDING - wcswidth(cell)) - ) - .join('') - .trimEnd() - ) - } + return [headers, ...rows].map((line) => + line + .map((cell, i) => + i === line.length - 1 + ? cell + : cell + ' '.repeat(widths[i] + COLUMN_PADDING - wcswidth(cell)) + ) + .join('') + .trimEnd() + ) } diff --git a/packages/cli/tests/commands/sandbox/info.test.ts b/packages/cli/tests/commands/sandbox/info.test.ts new file mode 100644 index 0000000000..4ee7b13411 --- /dev/null +++ b/packages/cli/tests/commands/sandbox/info.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { SidecarInfo } from 'e2b' + +import { + formatSidecarTable, + renderPrettyInfo, +} from '../../../src/commands/sandbox/info' + +const sidecars: SidecarInfo[] = [ + { + entry: 'valkey', + version: '7.4.1', + role: 'service', + class: 'stateful', + state: 'running', + name: 'valkey.sidecar.e2b.local', + address: '169.254.0.25', + ports: [6379], + }, + { + entry: 'iron-proxy', + version: '0.4.1', + role: 'proxy', + class: 'stateful', + state: 'failed', + name: 'iron-proxy.sidecar.e2b.local', + lastError: 'readiness probe timed out', + }, +] + +function capture() { + const lines: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => + lines.push(line) + ) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + return lines +} + +describe('sandbox info sidecars', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + test('formats a table with entry, version, role, class, state, name, address, ports and last error', () => { + expect(formatSidecarTable(sidecars)).toEqual([ + 'ENTRY VERSION ROLE CLASS STATE NAME ADDRESS PORTS LAST ERROR', + 'valkey 7.4.1 service stateful running valkey.sidecar.e2b.local 169.254.0.25 6379', + 'iron-proxy 0.4.1 proxy stateful failed iron-proxy.sidecar.e2b.local readiness probe timed out', + ]) + }) + + test('truncates a long last error to 60 characters with an ellipsis', () => { + const lastError = 'x'.repeat(70) + const [, row] = formatSidecarTable([{ ...sidecars[1], lastError }]) + + expect(row.endsWith(`${'x'.repeat(59)}…`)).toBe(true) + expect(row).not.toContain('x'.repeat(60)) + }) + + test('prints the sidecar table indented under its label', () => { + const output = capture() + + renderPrettyInfo({ sandboxId: 'sbx-1', sidecars }) + + // The label and its indented table go out as one multi-line log call. + const lines = output.join('\n').split('\n') + const start = lines.findIndex((line) => line.includes('Sidecars')) + expect(start).toBeGreaterThan(0) + expect(lines[start + 1]).toMatch(/^ ENTRY\s+VERSION/) + expect(lines[start + 2]).toMatch(/^ valkey\s+7\.4\.1/) + expect(lines[start + 3]).toMatch(/^ iron-proxy\s+0\.4\.1/) + }) + + test('omits the sidecars field when the sandbox has none', () => { + const lines = capture() + + renderPrettyInfo({ sandboxId: 'sbx-1', sidecars: [] }) + + expect(lines.some((line) => line.includes('Sidecars'))).toBe(false) + }) +}) diff --git a/packages/cli/tests/commands/sandbox/list.test.ts b/packages/cli/tests/commands/sandbox/list.test.ts index bf7c522570..73699e0e35 100644 --- a/packages/cli/tests/commands/sandbox/list.test.ts +++ b/packages/cli/tests/commands/sandbox/list.test.ts @@ -3,6 +3,7 @@ import { SandboxInfo } from 'e2b' import { buildTableRows, + formatSidecars, sortSandboxes, } from '../../../src/commands/sandbox/list' @@ -60,6 +61,38 @@ describe('sandbox list table rows', () => { expect(row.metadata).toBe('{}') }) + test('lists sidecars as entry:state pairs, empty when there are none', () => { + const startedAt = new Date('2026-09-01T10:00:00Z') + const [withSidecars, without] = buildTableRows([ + { + ...sandbox('sbx-a', startedAt), + sidecars: [ + { + entry: 'valkey', + version: '7.4.1', + role: 'service', + class: 'stateful', + state: 'running', + name: 'valkey.sidecar.e2b.local', + }, + { + entry: 'iron-proxy', + version: '0.4.1', + role: 'proxy', + class: 'stateful', + state: 'failed', + name: 'iron-proxy.sidecar.e2b.local', + }, + ], + }, + sandbox('sbx-b', startedAt), + ]) + + expect(withSidecars.sidecars).toBe('valkey:running,iron-proxy:failed') + expect(without.sidecars).toBe('') + expect(formatSidecars(undefined)).toBe('') + }) + test('does not mutate the input array', () => { const september = sandbox('sbx-sep', new Date('2026-09-01T10:00:00Z')) const october = sandbox('sbx-oct', new Date('2026-10-01T09:00:00Z')) diff --git a/packages/cli/tests/utils/table.test.ts b/packages/cli/tests/utils/table.test.ts index df8ef1ab2e..7e14c465ed 100644 --- a/packages/cli/tests/utils/table.test.ts +++ b/packages/cli/tests/utils/table.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { renderTable } from '../../src/utils/table' +import { formatTable, renderTable } from '../../src/utils/table' describe('renderTable', () => { afterEach(() => { @@ -50,6 +50,21 @@ describe('renderTable', () => { expect(lines).toEqual(['A B', 'x']) }) + it('formats the same lines without printing them', () => { + const lines = capture() + + const formatted = formatTable( + [{ id: 'sbx-1', name: 'alpha' }], + [ + { header: 'Sandbox ID', value: (row) => row.id }, + { header: 'Name', value: (row) => row.name }, + ] + ) + + expect(formatted).toEqual(['SANDBOX ID NAME', 'sbx-1 alpha']) + expect(lines).toEqual([]) + }) + it('aligns columns containing wide (CJK) characters by display width', () => { const lines = capture() @@ -64,10 +79,6 @@ describe('renderTable', () => { ] ) - expect(lines).toEqual([ - 'NAME STATE', - '日本語 ok', - 'abcdef ok', - ]) + expect(lines).toEqual(['NAME STATE', '日本語 ok', 'abcdef ok']) }) }) diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 8943b3385e..ccb9ecdf94 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -2282,6 +2282,7 @@ export interface components { metadata?: components["schemas"]["SandboxMetadata"]; /** @description Identifier of the sandbox */ sandboxID: string; + sidecars?: components["schemas"]["SidecarInfo"][]; /** * Format: date-time * @description Time when the sandbox was started @@ -2358,6 +2359,23 @@ export interface components { network?: components["schemas"]["SandboxNetworkConfig"]; /** @description Secure all system communication with sandbox */ secure?: boolean; + /** @description Sidecar microVMs to attach to the sandbox, at most four, at most one with the proxy role. Requires the team's sandbox-sidecars feature. */ + sidecars?: [ + ] | [ + components["schemas"]["SidecarAttachment"] + ] | [ + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"] + ] | [ + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"] + ] | [ + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"], + components["schemas"]["SidecarAttachment"] + ]; /** @description Identifier of the required template */ templateID: string; /** @@ -2415,6 +2433,7 @@ export interface components { envdVersion: components["schemas"]["EnvdVersion"]; /** @description Identifier of the sandbox */ sandboxID: string; + sidecars?: components["schemas"]["SidecarInfo"][]; /** @description Identifier of the template from which is the sandbox created */ templateID: string; /** @description Token required for accessing sandbox via proxy. */ @@ -2457,6 +2476,7 @@ export interface components { network?: components["schemas"]["SandboxNetworkConfig"]; /** @description Identifier of the sandbox */ sandboxID: string; + sidecars?: components["schemas"]["SidecarInfo"][]; /** * Format: date-time * @description Time when the sandbox was started @@ -2723,6 +2743,48 @@ export interface components { /** @description Runtime marker stored as the secret's new version. The runtime resolves it to a value at sandbox egress. */ value: string; }; + /** @description A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. */ + SidecarAttachment: { + /** @description Entry-specific configuration, validated against the entry's schema. String values may reference secrets as "${e2b.secrets.}". */ + config?: { + [key: string]: unknown; + }; + /** @description Catalog entry name (for example "iron-proxy" or "valkey"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". */ + entry: string; + /** @description Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. */ + secrets?: { + [key: string]: string; + }; + /** @description Catalog entry version. Defaults to the entry's current version. */ + version?: string; + }; + /** @description A sidecar attached to the sandbox and its current state. */ + SidecarInfo: { + /** @description Address of the sidecar inside the sandbox network */ + address?: string; + /** + * @description Lifecycle class of the sidecar + * @enum {string} + */ + class: "ephemeral" | "stateful"; + /** @description Catalog entry name */ + entry: string; + /** @description Last error of the sidecar, set when the state is failed */ + lastError?: string; + /** @description Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") */ + name: string; + /** @description Ports the sidecar listens on */ + ports?: number[]; + /** + * @description Role of the sidecar + * @enum {string} + */ + role: "proxy" | "service"; + /** @description Current state of the sidecar. Not a closed set; current values are starting, running, failed and stopped. */ + state: string; + /** @description Catalog entry version */ + version: string; + }; SnapshotInfo: { /** @description Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) */ names: string[]; diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 1a49508e63..758916ee04 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -85,6 +85,11 @@ export type { SandboxNetworkTransformContext, SandboxNetworkTransformResolver, SandboxNetworkUpdate, + SidecarAttachment, + SidecarInfo, + SidecarRole, + SidecarClass, + SidecarState, SandboxOnTimeout, SandboxLifecycle, SandboxInfoLifecycle, diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 28a2183a0d..b09d83312d 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -575,6 +575,112 @@ type SandboxForkResponse = } | Error +/** + * Sidecar microVM to attach to a sandbox at creation, declared from the E2B + * sidecar catalog. + * + * A sidecar runs next to the sandbox inside its private network, and code in + * the sandbox reaches it by the name `{entry}.sidecar.e2b.local`, never by IP. + * The catalog has four entries: + * - `'iron-proxy'` (proxy role): the sandbox's egress is steered through it + * and it swaps a placeholder token for the real secret value on the way + * out, so the secret never enters the sandbox. + * - `'valkey'` (service role): a Valkey (Redis-compatible) cache the sandbox + * talks to directly, on port 6379. + * - `'sqlite'` (service role): libsql-server over HTTP at + * `http://sqlite.sidecar.e2b.local:8080`. + * - `'iroh'` (service role): a peer-to-peer tunnel configured with `pipes` of + * `publish` / `connect`; tickets are served at + * `http://iroh.sidecar.e2b.local:8080/tickets.json`, to be polled until + * `status == "ready"`. Takes an optional `node_secret` secret slot. A + * forked sandbox's iroh sidecar starts with a fresh peer identity. + * + * A sidecar follows the sandbox's lifecycle: it is paused and snapshotted + * with the sandbox, comes back exactly as it was on resume (its data + * included), is forked with it, and is terminated with it. A sidecar that + * crashes is restarted once from its clean image and then reported + * `'failed'`; the sandbox keeps running. Attaching one requires the team's + * `sandbox-sidecars` feature. + * + * @example + * ```ts + * const sandbox = await Sandbox.create({ + * sidecars: [ + * { entry: 'valkey' }, + * { + * entry: 'iron-proxy', + * // Slot names and config keys are defined by the catalog entry. + * secrets: { upstream: '${e2b.secrets.openai-key}' }, + * }, + * ], + * }) + * ``` + */ +export type SidecarAttachment = { + /** Catalog entry name: `'iron-proxy'`, `'valkey'`, `'sqlite'` or `'iroh'`. */ + entry: string + + /** Catalog entry version. Defaults to the entry's current version. */ + version?: string + + /** + * Entry-specific configuration, validated against the entry's schema by the + * API. String values may reference a secret as `'${e2b.secrets.}'`; + * the platform resolves the reference when it injects the configuration, + * so the value never passes through the SDK. + */ + config?: Record + + /** + * Secret slots the entry declares, keyed by slot name. Each value is a + * secret reference (`'${e2b.secrets.}'`), never the secret itself. + * Every slot the entry declares has to be filled. + */ + secrets?: Record +} + +/** + * Role of a sidecar: `'proxy'` steers the sandbox's egress through it, + * `'service'` is reached by the sandbox directly. + */ +export type SidecarRole = 'proxy' | 'service' + +/** + * Lifecycle class of a sidecar, as reported by the API; every catalog entry + * today is `'stateful'`. Kept for wire stability. + */ +export type SidecarClass = 'ephemeral' | 'stateful' + +/** + * State of a sidecar. `'failed'` is reached after one automatic restart + * attempt; the sandbox itself keeps running. The set is defined server-side + * and may grow, so any string is allowed. + */ +export type SidecarState = + 'starting' | 'running' | 'failed' | 'stopped' | (string & {}) + +/** + * A sidecar attached to a sandbox, as returned by the sandbox info and list + * endpoints. + */ +export type SidecarInfo = { + /** Catalog entry name. */ + entry: string + /** Catalog entry version. */ + version: string + role: SidecarRole + class: SidecarClass + state: SidecarState + /** Name the sandbox reaches the sidecar at (`{entry}.sidecar.e2b.local`). */ + name: string + /** Address of the sidecar inside the sandbox network. */ + address?: string + /** Ports the sidecar listens on. */ + ports?: number[] + /** Last error of the sidecar, set when `state` is `'failed'`. */ + lastError?: string +} + /** * Options for creating a new Sandbox. */ @@ -670,6 +776,14 @@ export interface SandboxOpts extends ConnectionOpts { */ volumeMounts?: Record + /** + * Sidecar microVMs to attach to the sandbox — at most four, at most one + * with the proxy role. See {@link SidecarAttachment}. + * + * @default undefined + */ + sidecars?: SidecarAttachment[] + /** * Sandbox URL. Used for local development */ @@ -919,6 +1033,12 @@ export interface SandboxInfo { */ volumeMounts?: Array<{ name: string; path: string }> + /** + * Sidecars attached to the sandbox, empty when there are none. See + * {@link SidecarInfo}. + */ + sidecars?: SidecarInfo[] + /** * Sandbox domain. */ @@ -1149,6 +1269,84 @@ function fromApiEgressProxy( } } +// The spec's maxItems renders as a tuple union; the count is the API's to +// enforce (sidecar_limit), so the list is cast rather than re-validated here. +function buildSidecarsBody( + sidecars: SidecarAttachment[] +): NonNullable { + if (!Array.isArray(sidecars)) { + throw new InvalidArgumentError( + `sidecars must be an array of { entry, version?, config?, secrets? } (got ${describeValue(sidecars)}).` + ) + } + + return sidecars.map((sidecar, i) => { + if (!isPlainObject(sidecar) || typeof sidecar.entry !== 'string') { + throw new InvalidArgumentError( + `sidecars[${i}] must be an object with a string 'entry' naming a catalog entry (e.g. 'valkey').` + ) + } + + return { + entry: sidecar.entry, + ...(sidecar.version != null ? { version: sidecar.version } : {}), + ...(sidecar.config != null ? { config: sidecar.config } : {}), + ...(sidecar.secrets != null ? { secrets: sidecar.secrets } : {}), + } + }) as NonNullable +} + +function fromApiSidecars( + sidecars: components['schemas']['SidecarInfo'][] | undefined +): SidecarInfo[] { + return (sidecars ?? []).map((sidecar) => ({ + entry: sidecar.entry, + version: sidecar.version, + role: sidecar.role, + class: sidecar.class, + state: sidecar.state, + name: sidecar.name, + ...(sidecar.address !== undefined ? { address: sidecar.address } : {}), + ...(sidecar.ports !== undefined ? { ports: sidecar.ports } : {}), + ...(sidecar.lastError !== undefined + ? { lastError: sidecar.lastError } + : {}), + })) +} + +/** + * Sidecar rejections carry a lower-snake `sidecar_*` semantic code, like every + * other `error_code`. Validation failures are 400 — `sidecar_unknown_entry`, + * `sidecar_deprecated_entry`, `sidecar_limit`, `sidecar_one_proxy`, + * `sidecar_config_invalid`, `sidecar_secret_missing`, + * `sidecar_rule_collision`, `sidecar_egress_conflict`, `sidecar_flag_off` — + * and a sidecar that did not start is `sidecar_failed` naming the entry. On + * resume, `sidecar_version_unavailable` (409: the catalog version the sidecar + * was snapshotted with has been removed; the sandbox stays paused) and + * `sidecar_snapshot_mismatch` (500: a stored sidecar snapshot has no matching + * declaration) stay {@link SandboxError}s — the SDK has no conflict type. The + * code stays in the message so callers can tell them apart. + */ +function sidecarApiError(res: { + response: { status: number; statusText: string } + error?: unknown +}): Error | undefined { + const body = isPlainObject(res.error) ? res.error : undefined + const code = body?.error_code + if (typeof code !== 'string' || !code.startsWith('sidecar_')) { + return + } + + const status = res.response.status + const message = `${code}: ${body?.message ?? res.response.statusText}` + const err = + status === 400 + ? new InvalidArgumentError(message) + : new SandboxError(message) + err.statusCode = status + return err +} + function buildNetworkBody( network: SandboxNetworkOpts | undefined, iam: components['schemas']['SandboxIam'] | undefined @@ -1344,6 +1542,7 @@ export class SandboxApi extends ClientFactory { : undefined, sandboxDomain: res.data.domain || undefined, volumeMounts: res.data.volumeMounts ?? [], + sidecars: fromApiSidecars(res.data.sidecars), } } @@ -1503,7 +1702,7 @@ export class SandboxApi extends ClientFactory { throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`) } - const err = handleApiError(res) + const err = sidecarApiError(res) ?? handleApiError(res) if (err) { throw err } @@ -1744,6 +1943,13 @@ export class SandboxApi extends ClientFactory { ) } + if (opts?.sidecars != null) { + const sidecars = buildSidecarsBody(opts.sidecars) + if (sidecars.length) { + body.sidecars = sidecars + } + } + const apiOpts = this.resolveOpts(opts) const config = new ConnectionConfig(apiOpts) const client = new ApiClient(config) @@ -1752,7 +1958,7 @@ export class SandboxApi extends ClientFactory { signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) - const err = handleApiError(res) + const err = sidecarApiError(res) ?? handleApiError(res) if (err) { throw err } @@ -1881,7 +2087,7 @@ export class SandboxApi extends ClientFactory { throw new SandboxNotFoundError(`Paused sandbox ${sandboxId} not found`) } - const err = handleApiError(res) + const err = sidecarApiError(res) ?? handleApiError(res) if (err) { throw err } @@ -1975,6 +2181,7 @@ export class SandboxPaginator extends Paginator { memoryMB: sandbox.memoryMB, envdVersion: sandbox.envdVersion, volumeMounts: sandbox.volumeMounts ?? [], + sidecars: fromApiSidecars(sandbox.sidecars), }) ) } diff --git a/packages/js-sdk/tests/sandbox/sidecars.test.ts b/packages/js-sdk/tests/sandbox/sidecars.test.ts new file mode 100644 index 0000000000..ed7ebf5ab1 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/sidecars.test.ts @@ -0,0 +1,333 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { InvalidArgumentError, Sandbox, SandboxError } from '../../src' +import { TEST_API_KEY, apiUrl } from '../setup' + +const sandboxId = 'test-sandbox-id' + +const valkeyInfo = { + entry: 'valkey', + version: '7.4.1', + role: 'service', + class: 'stateful', + state: 'running', + name: 'valkey.sidecar.e2b.local', + address: '169.254.0.25', + ports: [6379], +} + +const failedProxyInfo = { + entry: 'iron-proxy', + version: '0.4.1', + role: 'proxy', + class: 'stateful', + state: 'failed', + name: 'iron-proxy.sidecar.e2b.local', + lastError: 'readiness probe timed out', +} + +const sandboxDetail = { + sandboxID: sandboxId, + templateID: 'base', + clientID: 'test-client', + envdVersion: '0.2.4', + startedAt: '2026-01-01T00:00:00Z', + endAt: '2026-01-01T01:00:00Z', + state: 'running', + cpuCount: 2, + memoryMB: 512, + diskSizeMB: 1024, +} + +let lastCreateBody: Record | undefined +let createResponse: () => HttpResponse +let connectResponse: () => HttpResponse = () => + HttpResponse.json({ sandboxID: sandboxId, envdVersion: '0.2.4' }) +let infoSidecars: unknown[] | undefined + +const server = setupServer( + http.post(apiUrl('/sandboxes'), async ({ request }) => { + lastCreateBody = (await request.json()) as Record + return createResponse() + }), + http.get(apiUrl(`/sandboxes/${sandboxId}`), () => + HttpResponse.json({ ...sandboxDetail, sidecars: infoSidecars }) + ), + http.get(apiUrl('/v2/sandboxes'), () => + HttpResponse.json([{ ...sandboxDetail, sidecars: infoSidecars }]) + ), + http.post(apiUrl(`/sandboxes/${sandboxId}/connect`), () => connectResponse()), + http.put(apiUrl(`/sandboxes/${sandboxId}/network`), () => + HttpResponse.json( + { + code: 400, + error_code: 'sidecar_rule_collision', + message: 'api.openai.com is routed through the iron-proxy sidecar', + }, + { status: 400 } + ) + ) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterAll(() => server.close()) + +afterEach(() => { + lastCreateBody = undefined + infoSidecars = undefined + createResponse = () => + HttpResponse.json({ + sandboxID: sandboxId, + templateID: 'base', + envdVersion: '0.2.4', + }) + server.resetHandlers() +}) + +createResponse = () => + HttpResponse.json({ + sandboxID: sandboxId, + templateID: 'base', + envdVersion: '0.2.4', + }) + +test('Sandbox.create sends the sidecars in the request body', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [ + { entry: 'valkey' }, + { + entry: 'iron-proxy', + version: '0.4.1', + config: { allow: ['api.openai.com'] }, + secrets: { upstream: '${e2b.secrets.openai-key}' }, + }, + ], + }) + + expect(lastCreateBody?.sidecars).toEqual([ + { entry: 'valkey' }, + { + entry: 'iron-proxy', + version: '0.4.1', + config: { allow: ['api.openai.com'] }, + secrets: { upstream: '${e2b.secrets.openai-key}' }, + }, + ]) +}) + +test.each([ + ['not provided', {}], + ['an empty list', { sidecars: [] }], + ['null', { sidecars: null as any }], +])('Sandbox.create omits sidecars when %s', async (_, opts) => { + await Sandbox.create('base', { apiKey: TEST_API_KEY, ...opts }) + + expect(lastCreateBody).toBeDefined() + expect(lastCreateBody).not.toHaveProperty('sidecars') +}) + +test('Sandbox.create strips unknown sidecar properties', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [ + // An untyped caller can copy an extra key out of a config file; the + // API rejects unknown properties. + { entry: 'valkey', image: 'valkey:7' } as any, + ], + }) + + expect(lastCreateBody?.sidecars).toEqual([{ entry: 'valkey' }]) +}) + +test('Sandbox.create rejects a sidecar without an entry before any request', async () => { + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ version: '7.4.1' } as any], + }) + ).rejects.toThrowError(InvalidArgumentError) + + await expect( + Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: { entry: 'valkey' } as any, + }) + ).rejects.toThrowError(InvalidArgumentError) + + expect(lastCreateBody).toBeUndefined() +}) + +test('Sandbox.getInfo returns the sidecars with their state', async () => { + infoSidecars = [valkeyInfo, failedProxyInfo] + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.sidecars).toEqual([valkeyInfo, failedProxyInfo]) +}) + +test('Sandbox.getInfo passes through a state value the SDK does not know', async () => { + infoSidecars = [{ ...valkeyInfo, state: 'restarting' }] + + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.sidecars?.[0].state).toBe('restarting') +}) + +test('Sandbox.getInfo returns an empty sidecar list when the API sends none', async () => { + const info = await Sandbox.getInfo(sandboxId, { apiKey: TEST_API_KEY }) + + expect(info.sidecars).toEqual([]) +}) + +test('Sandbox.list returns the sidecars of each sandbox', async () => { + infoSidecars = [valkeyInfo] + + const [info] = await Sandbox.list({ apiKey: TEST_API_KEY }).nextItems() + + expect(info.sidecars).toEqual([valkeyInfo]) +}) + +test('a sidecar_* 400 surfaces as InvalidArgumentError with the code preserved', async () => { + createResponse = () => + HttpResponse.json( + { + code: 400, + error_code: 'sidecar_unknown_entry', + message: 'unknown sidecar entry "memcached"', + }, + { status: 400 } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'memcached' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(InvalidArgumentError) + expect((err as SandboxError).statusCode).toBe(400) + expect((err as Error).message).toContain('sidecar_unknown_entry') + expect((err as Error).message).toContain('memcached') +}) + +test('sidecar_failed keeps the entry name and is not an argument error', async () => { + createResponse = () => + HttpResponse.json( + { + code: 500, + error_code: 'sidecar_failed', + message: 'sidecar "valkey" failed to become ready', + }, + { status: 500 } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'valkey' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as SandboxError).statusCode).toBe(500) + expect((err as Error).message).toContain('sidecar_failed') + expect((err as Error).message).toContain('valkey') +}) + +test.each([ + 'sidecar_unknown_entry', + 'sidecar_deprecated_entry', + 'sidecar_limit', + 'sidecar_one_proxy', + 'sidecar_config_invalid', + 'sidecar_secret_missing', + 'sidecar_rule_collision', + 'sidecar_egress_conflict', + 'sidecar_flag_off', +])('every 400 sidecar code (%s) is an InvalidArgumentError', async (code) => { + createResponse = () => + HttpResponse.json( + { code: 400, error_code: code, message: 'rejected' }, + { + status: 400, + } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'valkey' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toBe(`${code}: rejected`) +}) + +test('the sidecar code match is case-sensitive', async () => { + createResponse = () => + HttpResponse.json( + { code: 400, error_code: 'SIDECAR_UNKNOWN_ENTRY', message: 'nope' }, + { status: 400 } + ) + + const err = await Sandbox.create('base', { + apiKey: TEST_API_KEY, + sidecars: [{ entry: 'memcached' }], + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toBe('400: nope') +}) + +test('a 400 without a sidecar code keeps the generic mapping', async () => { + createResponse = () => + HttpResponse.json( + { code: 400, message: 'invalid template' }, + { status: 400 } + ) + + const err = await Sandbox.create('base', { apiKey: TEST_API_KEY }).catch( + (e: unknown) => e + ) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toBe('400: invalid template') +}) + +test.each([ + [ + 'sidecar_version_unavailable', + 409, + 'catalog version valkey@7.4.0 is no longer available; sandbox stays paused', + ], + ['sidecar_snapshot_mismatch', 500, 'snapshot for iroh has no declaration'], +])( + 'Sandbox.connect surfaces %s as a SandboxError with the code and status', + async (code, status, message) => { + connectResponse = () => + HttpResponse.json({ code: status, error_code: code, message }, { status }) + + const err = await Sandbox.connect(sandboxId, { + apiKey: TEST_API_KEY, + }).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(SandboxError) + expect(err).not.toBeInstanceOf(InvalidArgumentError) + expect((err as SandboxError).statusCode).toBe(status) + expect((err as Error).message).toBe(`${code}: ${message}`) + } +) + +test('Sandbox.updateNetwork surfaces sidecar_rule_collision as InvalidArgumentError', async () => { + const err = await Sandbox.updateNetwork( + sandboxId, + { allowOut: ['api.openai.com'] }, + { apiKey: TEST_API_KEY } + ).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(InvalidArgumentError) + expect((err as Error).message).toContain('sidecar_rule_collision') +}) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index ff010de53d..2201700798 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -98,6 +98,11 @@ SandboxNetworkSelector, SandboxNetworkSelectorContext, SandboxNetworkTransform, + SidecarAttachment, + SidecarClass, + SidecarInfo, + SidecarRole, + SidecarState, SandboxNetworkTransformContext, SandboxNetworkTransformResolver, SandboxNetworkUpdate, @@ -230,6 +235,12 @@ "SandboxNetworkRule", "SandboxNetworkRuleInfo", "SandboxNetworkRules", + # Sidecars + "SidecarAttachment", + "SidecarInfo", + "SidecarRole", + "SidecarClass", + "SidecarState", "SandboxNetworkTransform", "SandboxNetworkTransformContext", "SandboxNetworkTransformResolver", diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index d2acfa930d..1646a5aadc 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -57,6 +57,12 @@ from .secret import Secret from .secret_metadata import SecretMetadata from .secret_update import SecretUpdate +from .sidecar_attachment import SidecarAttachment +from .sidecar_attachment_config import SidecarAttachmentConfig +from .sidecar_attachment_secrets import SidecarAttachmentSecrets +from .sidecar_info import SidecarInfo +from .sidecar_info_class import SidecarInfoClass +from .sidecar_info_role import SidecarInfoRole from .snapshot_info import SnapshotInfo from .team_user import TeamUser from .template import Template @@ -138,6 +144,12 @@ "Secret", "SecretMetadata", "SecretUpdate", + "SidecarAttachment", + "SidecarAttachmentConfig", + "SidecarAttachmentSecrets", + "SidecarInfo", + "SidecarInfoClass", + "SidecarInfoRole", "SnapshotInfo", "TeamUser", "Template", diff --git a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py index aa14e7efc8..f7c01e4145 100644 --- a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo T = TypeVar("T", bound="ListedSandbox") @@ -33,6 +34,7 @@ class ListedSandbox: alias (Union[Unset, str]): Alias of the template metadata (Union[Unset, Any]): volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + sidecars (Union[Unset, list['SidecarInfo']]): """ template_id: str @@ -48,6 +50,7 @@ class ListedSandbox: alias: Union[Unset, str] = UNSET metadata: Union[Unset, Any] = UNSET volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + sidecars: Union[Unset, list["SidecarInfo"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -82,6 +85,13 @@ def to_dict(self) -> dict[str, Any]: volume_mounts_item = volume_mounts_item_data.to_dict() volume_mounts.append(volume_mounts_item) + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -104,12 +114,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["metadata"] = metadata if volume_mounts is not UNSET: field_dict["volumeMounts"] = volume_mounts + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo d = dict(src_dict) template_id = d.pop("templateID") @@ -143,6 +156,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: volume_mounts.append(volume_mounts_item) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarInfo.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + listed_sandbox = cls( template_id=template_id, sandbox_id=sandbox_id, @@ -157,6 +177,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: alias=alias, metadata=metadata, volume_mounts=volume_mounts, + sidecars=sidecars, ) listed_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py index c511ffff17..f5a18ee955 100644 --- a/packages/python-sdk/e2b/api/client/models/new_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -12,6 +12,7 @@ from ..models.sandbox_iam import SandboxIam from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_attachment import SidecarAttachment T = TypeVar("T", bound="NewSandbox") @@ -40,6 +41,8 @@ class NewSandbox: iam (Union[Unset, SandboxIam]): Sandbox workload identity configuration. A non-empty, valid tokens map enables workload identity for the sandbox. volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + sidecars (Union[Unset, list['SidecarAttachment']]): Sidecar microVMs to attach to the sandbox, at most four, at + most one with the proxy role. Requires the team's sandbox-sidecars feature. """ template_id: str @@ -55,6 +58,7 @@ class NewSandbox: mcp: Union["McpType0", None, Unset] = UNSET iam: Union[Unset, "SandboxIam"] = UNSET volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + sidecars: Union[Unset, list["SidecarAttachment"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -103,6 +107,13 @@ def to_dict(self) -> dict[str, Any]: volume_mounts_item = volume_mounts_item_data.to_dict() volume_mounts.append(volume_mounts_item) + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -134,6 +145,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["iam"] = iam if volume_mounts is not UNSET: field_dict["volumeMounts"] = volume_mounts + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @@ -144,6 +157,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.sandbox_iam import SandboxIam from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_attachment import SidecarAttachment d = dict(src_dict) template_id = d.pop("templateID") @@ -207,6 +221,13 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: volume_mounts.append(volume_mounts_item) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarAttachment.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + new_sandbox = cls( template_id=template_id, timeout=timeout, @@ -221,6 +242,7 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: mcp=mcp, iam=iam, volume_mounts=volume_mounts, + sidecars=sidecars, ) new_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox.py b/packages/python-sdk/e2b/api/client/models/sandbox.py index 651a13205f..8cbe8faed6 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox.py @@ -1,11 +1,15 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sidecar_info import SidecarInfo + + T = TypeVar("T", bound="Sandbox") @@ -21,6 +25,7 @@ class Sandbox: envd_access_token (Union[Unset, str]): Access token used for envd communication traffic_access_token (Union[None, Unset, str]): Token required for accessing sandbox via proxy. domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible + sidecars (Union[Unset, list['SidecarInfo']]): """ template_id: str @@ -31,6 +36,7 @@ class Sandbox: envd_access_token: Union[Unset, str] = UNSET traffic_access_token: Union[None, Unset, str] = UNSET domain: Union[None, Unset, str] = UNSET + sidecars: Union[Unset, list["SidecarInfo"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -58,6 +64,13 @@ def to_dict(self) -> dict[str, Any]: else: domain = self.domain + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -76,11 +89,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["trafficAccessToken"] = traffic_access_token if domain is not UNSET: field_dict["domain"] = domain + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sidecar_info import SidecarInfo + d = dict(src_dict) template_id = d.pop("templateID") @@ -114,6 +131,13 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: domain = _parse_domain(d.pop("domain", UNSET)) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarInfo.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + sandbox = cls( template_id=template_id, sandbox_id=sandbox_id, @@ -123,6 +147,7 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: envd_access_token=envd_access_token, traffic_access_token=traffic_access_token, domain=domain, + sidecars=sidecars, ) sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py index dfe232203c..702564f832 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -13,6 +13,7 @@ from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo T = TypeVar("T", bound="SandboxDetail") @@ -41,6 +42,7 @@ class SandboxDetail: network (Union[Unset, SandboxNetworkConfig]): lifecycle (Union[Unset, SandboxLifecycle]): Sandbox lifecycle policy returned by sandbox info. volume_mounts (Union[Unset, list['SandboxVolumeMount']]): + sidecars (Union[Unset, list['SidecarInfo']]): """ template_id: str @@ -61,6 +63,7 @@ class SandboxDetail: network: Union[Unset, "SandboxNetworkConfig"] = UNSET lifecycle: Union[Unset, "SandboxLifecycle"] = UNSET volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET + sidecars: Union[Unset, list["SidecarInfo"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -117,6 +120,13 @@ def to_dict(self) -> dict[str, Any]: volume_mounts_item = volume_mounts_item_data.to_dict() volume_mounts.append(volume_mounts_item) + sidecars: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.sidecars, Unset): + sidecars = [] + for sidecars_item_data in self.sidecars: + sidecars_item = sidecars_item_data.to_dict() + sidecars.append(sidecars_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -149,6 +159,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["lifecycle"] = lifecycle if volume_mounts is not UNSET: field_dict["volumeMounts"] = volume_mounts + if sidecars is not UNSET: + field_dict["sidecars"] = sidecars return field_dict @@ -157,6 +169,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.sandbox_lifecycle import SandboxLifecycle from ..models.sandbox_network_config import SandboxNetworkConfig from ..models.sandbox_volume_mount import SandboxVolumeMount + from ..models.sidecar_info import SidecarInfo d = dict(src_dict) template_id = d.pop("templateID") @@ -226,6 +239,13 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: volume_mounts.append(volume_mounts_item) + sidecars = [] + _sidecars = d.pop("sidecars", UNSET) + for sidecars_item_data in _sidecars or []: + sidecars_item = SidecarInfo.from_dict(sidecars_item_data) + + sidecars.append(sidecars_item) + sandbox_detail = cls( template_id=template_id, sandbox_id=sandbox_id, @@ -245,6 +265,7 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: network=network, lifecycle=lifecycle, volume_mounts=volume_mounts, + sidecars=sidecars, ) sandbox_detail.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py new file mode 100644 index 0000000000..d54cda43d2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment.py @@ -0,0 +1,114 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.sidecar_attachment_config import SidecarAttachmentConfig + from ..models.sidecar_attachment_secrets import SidecarAttachmentSecrets + + +T = TypeVar("T", bound="SidecarAttachment") + + +@_attrs_define +class SidecarAttachment: + """A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. + + Attributes: + entry (str): Catalog entry name (for example "iron-proxy" or "valkey"). The sandbox reaches the sidecar at + "{entry}.sidecar.e2b.local". + version (Union[Unset, str]): Catalog entry version. Defaults to the entry's current version. + config (Union[Unset, SidecarAttachmentConfig]): Entry-specific configuration, validated against the entry's + schema. String values may reference secrets as "${e2b.secrets.}". + secrets (Union[Unset, SidecarAttachmentSecrets]): Secret slots the entry declares, keyed by slot name, each + holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. + """ + + entry: str + version: Union[Unset, str] = UNSET + config: Union[Unset, "SidecarAttachmentConfig"] = UNSET + secrets: Union[Unset, "SidecarAttachmentSecrets"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entry = self.entry + + version = self.version + + config: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.config, Unset): + config = self.config.to_dict() + + secrets: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.secrets, Unset): + secrets = self.secrets.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entry": entry, + } + ) + if version is not UNSET: + field_dict["version"] = version + if config is not UNSET: + field_dict["config"] = config + if secrets is not UNSET: + field_dict["secrets"] = secrets + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sidecar_attachment_config import SidecarAttachmentConfig + from ..models.sidecar_attachment_secrets import SidecarAttachmentSecrets + + d = dict(src_dict) + entry = d.pop("entry") + + version = d.pop("version", UNSET) + + _config = d.pop("config", UNSET) + config: Union[Unset, SidecarAttachmentConfig] + if isinstance(_config, Unset): + config = UNSET + else: + config = SidecarAttachmentConfig.from_dict(_config) + + _secrets = d.pop("secrets", UNSET) + secrets: Union[Unset, SidecarAttachmentSecrets] + if isinstance(_secrets, Unset): + secrets = UNSET + else: + secrets = SidecarAttachmentSecrets.from_dict(_secrets) + + sidecar_attachment = cls( + entry=entry, + version=version, + config=config, + secrets=secrets, + ) + + sidecar_attachment.additional_properties = d + return sidecar_attachment + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py new file mode 100644 index 0000000000..2f9577e506 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_config.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SidecarAttachmentConfig") + + +@_attrs_define +class SidecarAttachmentConfig: + """Entry-specific configuration, validated against the entry's schema. String values may reference secrets as + "${e2b.secrets.}". + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sidecar_attachment_config = cls() + + sidecar_attachment_config.additional_properties = d + return sidecar_attachment_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py new file mode 100644 index 0000000000..04b9693758 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_attachment_secrets.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SidecarAttachmentSecrets") + + +@_attrs_define +class SidecarAttachmentSecrets: + """Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at + injection time. The secret value never enters the sandbox. + + """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sidecar_attachment_secrets = cls() + + sidecar_attachment_secrets.additional_properties = d + return sidecar_attachment_secrets + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info.py b/packages/python-sdk/e2b/api/client/models/sidecar_info.py new file mode 100644 index 0000000000..728ca55306 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info.py @@ -0,0 +1,134 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.sidecar_info_class import SidecarInfoClass +from ..models.sidecar_info_role import SidecarInfoRole +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SidecarInfo") + + +@_attrs_define +class SidecarInfo: + """A sidecar attached to the sandbox and its current state. + + Attributes: + entry (str): Catalog entry name + version (str): Catalog entry version + role (SidecarInfoRole): Role of the sidecar + class_ (SidecarInfoClass): Lifecycle class of the sidecar + state (str): Current state of the sidecar. Not a closed set; current values are starting, running, failed and + stopped. + name (str): Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") + address (Union[Unset, str]): Address of the sidecar inside the sandbox network + ports (Union[Unset, list[int]]): Ports the sidecar listens on + last_error (Union[Unset, str]): Last error of the sidecar, set when the state is failed + """ + + entry: str + version: str + role: SidecarInfoRole + class_: SidecarInfoClass + state: str + name: str + address: Union[Unset, str] = UNSET + ports: Union[Unset, list[int]] = UNSET + last_error: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + entry = self.entry + + version = self.version + + role = self.role.value + + class_ = self.class_.value + + state = self.state + + name = self.name + + address = self.address + + ports: Union[Unset, list[int]] = UNSET + if not isinstance(self.ports, Unset): + ports = self.ports + + last_error = self.last_error + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "entry": entry, + "version": version, + "role": role, + "class": class_, + "state": state, + "name": name, + } + ) + if address is not UNSET: + field_dict["address"] = address + if ports is not UNSET: + field_dict["ports"] = ports + if last_error is not UNSET: + field_dict["lastError"] = last_error + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + entry = d.pop("entry") + + version = d.pop("version") + + role = SidecarInfoRole(d.pop("role")) + + class_ = SidecarInfoClass(d.pop("class")) + + state = d.pop("state") + + name = d.pop("name") + + address = d.pop("address", UNSET) + + ports = cast(list[int], d.pop("ports", UNSET)) + + last_error = d.pop("lastError", UNSET) + + sidecar_info = cls( + entry=entry, + version=version, + role=role, + class_=class_, + state=state, + name=name, + address=address, + ports=ports, + last_error=last_error, + ) + + sidecar_info.additional_properties = d + return sidecar_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info_class.py b/packages/python-sdk/e2b/api/client/models/sidecar_info_class.py new file mode 100644 index 0000000000..d0209b1805 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info_class.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SidecarInfoClass(str, Enum): + EPHEMERAL = "ephemeral" + STATEFUL = "stateful" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sidecar_info_role.py b/packages/python-sdk/e2b/api/client/models/sidecar_info_role.py new file mode 100644 index 0000000000..8a6c12c729 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sidecar_info_role.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SidecarInfoRole(str, Enum): + PROXY = "proxy" + SERVICE = "service" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 372a1c409f..1909fa5fb8 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,4 +1,5 @@ import inspect +import json from dataclasses import dataclass, field from datetime import datetime from typing import ( @@ -61,9 +62,21 @@ from e2b.api.client.models import ( SandboxNetworkUpdateConfigRules, ) +from e2b.api.client.models import ( + SidecarAttachment as ClientSidecarAttachment, +) +from e2b.api.client.models import ( + SidecarAttachmentConfig as ClientSidecarAttachmentConfig, +) +from e2b.api.client.models import ( + SidecarAttachmentSecrets as ClientSidecarAttachmentSecrets, +) +from e2b.api.client.models import ( + SidecarInfo as ClientSidecarInfo, +) from e2b.api.client.types import UNSET, Unset from e2b.connection_config import ApiParams -from e2b.exceptions import InvalidArgumentException +from e2b.exceptions import InvalidArgumentException, SandboxException from e2b.sandbox.mcp import McpServer as BaseMcpServer from e2b.sandbox.iam import ( IamTokenPlaceholders, @@ -493,6 +506,113 @@ class SandboxNetworkInfo(TypedDict, total=False): https_ports: List[int] +class SidecarAttachment(TypedDict): + """ + Sidecar microVM to attach to a sandbox at creation, declared from the E2B + sidecar catalog. + + A sidecar runs next to the sandbox inside its private network, and code in + the sandbox reaches it by the name ``{entry}.sidecar.e2b.local``, never by + IP. The catalog has four entries: + + - ``"iron-proxy"`` (proxy role): the sandbox's egress is steered through it + and it swaps a placeholder token for the real secret value on the way + out, so the secret never enters the sandbox. + - ``"valkey"`` (service role): a Valkey (Redis-compatible) cache the sandbox + talks to directly, on port 6379. + - ``"sqlite"`` (service role): libsql-server over HTTP at + ``http://sqlite.sidecar.e2b.local:8080``. + - ``"iroh"`` (service role): a peer-to-peer tunnel configured with + ``pipes`` of ``publish`` / ``connect``; tickets are served at + ``http://iroh.sidecar.e2b.local:8080/tickets.json``, to be polled until + ``status == "ready"``. Takes an optional ``node_secret`` secret slot. A + forked sandbox's iroh sidecar starts with a fresh peer identity. + + A sidecar follows the sandbox's lifecycle: it is paused and snapshotted + with the sandbox, comes back exactly as it was on resume (its data + included), is forked with it, and is terminated with it. A sidecar that + crashes is restarted once from its clean image and then reported + ``"failed"``; the sandbox keeps running. Attaching one requires the team's + ``sandbox-sidecars`` feature:: + + sandbox = Sandbox.create( + sidecars=[ + {"entry": "valkey"}, + { + "entry": "iron-proxy", + # Slot names and config keys are defined by the catalog entry. + "secrets": {"upstream": "${e2b.secrets.openai-key}"}, + }, + ], + ) + """ + + entry: str + """Catalog entry name: ``"iron-proxy"``, ``"valkey"``, ``"sqlite"`` or ``"iroh"``.""" + + version: NotRequired[str] + """Catalog entry version. Defaults to the entry's current version.""" + + config: NotRequired[Dict[str, Any]] + """ + Entry-specific configuration, validated against the entry's schema by the + API. String values may reference a secret as ``"${e2b.secrets.}"``; + the platform resolves the reference when it injects the configuration, so + the value never passes through the SDK. + """ + + secrets: NotRequired[Dict[str, str]] + """ + Secret slots the entry declares, keyed by slot name. Each value is a secret + reference (``"${e2b.secrets.}"``), never the secret itself. Every + slot the entry declares has to be filled. + """ + + +SidecarRole = Literal["proxy", "service"] +""" +Role of a sidecar: ``"proxy"`` steers the sandbox's egress through it, +``"service"`` is reached by the sandbox directly. +""" + +SidecarClass = Literal["ephemeral", "stateful"] +""" +Lifecycle class of a sidecar, as reported by the API; every catalog entry today +is ``"stateful"``. Kept for wire stability. +""" + +SidecarState = Union[Literal["starting", "running", "failed", "stopped"], str] +""" +State of a sidecar. ``"failed"`` is reached after one automatic restart +attempt; the sandbox itself keeps running. The set is defined server-side and +may grow, so any string is allowed. +""" + + +@dataclass +class SidecarInfo: + """A sidecar attached to a sandbox, as returned by sandbox info and list.""" + + entry: str + """Catalog entry name.""" + version: str + """Catalog entry version.""" + role: SidecarRole + """Role of the sidecar.""" + class_: SidecarClass + """Lifecycle class of the sidecar (the wire field ``class``).""" + state: SidecarState + """Current state of the sidecar.""" + name: str + """Name the sandbox reaches the sidecar at (``{entry}.sidecar.e2b.local``).""" + address: Optional[str] = None + """Address of the sidecar inside the sandbox network.""" + ports: List[int] = field(default_factory=list) + """Ports the sidecar listens on.""" + last_error: Optional[str] = None + """Last error of the sidecar, set when ``state`` is ``"failed"``.""" + + class SandboxOnTimeoutPause(TypedDict): """ Object form of `on_timeout` that auto-pauses the sandbox when the timeout is @@ -796,6 +916,125 @@ def build_network_config( return body +def build_sidecars_body( + sidecars: Optional[List[SidecarAttachment]], +) -> Optional[List[ClientSidecarAttachment]]: + """Resolve the ``sidecars`` option into the API client body. + + Rebuilt from the known keys so stray keys in the caller's dicts never + reach the wire. Catalog membership, the count and the proxy limit are the + API's to check; only the shape an untyped caller can get wrong is checked + here, so the error names the option instead of surfacing as a ``KeyError``. + """ + if sidecars is None: + return None + + if isinstance(sidecars, (str, bytes, Mapping)) or not isinstance( + sidecars, Iterable + ): + raise InvalidArgumentException( + "sidecars must be a list of dicts with a string 'entry' " + "(e.g. [{'entry': 'valkey'}])." + ) + + body: List[ClientSidecarAttachment] = [] + for i, sidecar in enumerate(sidecars): + if not isinstance(sidecar, Mapping) or not isinstance( + sidecar.get("entry"), str + ): + raise InvalidArgumentException( + f"sidecars[{i}] must be a dict with a string 'entry' naming a " + "catalog entry (e.g. 'valkey')." + ) + + attachment = ClientSidecarAttachment(entry=sidecar["entry"]) + if sidecar.get("version") is not None: + attachment.version = sidecar["version"] + if sidecar.get("config") is not None: + if not isinstance(sidecar["config"], Mapping): + raise InvalidArgumentException( + f"sidecars[{i}].config must be a dict of entry-specific settings, " + f"got {type(sidecar['config']).__name__}." + ) + config = ClientSidecarAttachmentConfig() + config.additional_properties = dict(sidecar["config"]) + attachment.config = config + if sidecar.get("secrets") is not None: + if not isinstance(sidecar["secrets"], Mapping) or not all( + isinstance(k, str) and isinstance(v, str) + for k, v in sidecar["secrets"].items() + ): + raise InvalidArgumentException( + f"sidecars[{i}].secrets must be a dict of slot name to secret " + "reference string (e.g. {'upstream': '${e2b.secrets.}'})." + ) + secrets = ClientSidecarAttachmentSecrets() + secrets.additional_properties = dict(sidecar["secrets"]) + attachment.secrets = secrets + body.append(attachment) + + return body + + +def _from_client_sidecar(sidecar: ClientSidecarInfo) -> SidecarInfo: + # A wire null is neither Unset nor a value for the optional fields. + ports: List[int] = [] + if not isinstance(sidecar.ports, Unset) and sidecar.ports is not None: + ports = list(sidecar.ports) + return SidecarInfo( + entry=sidecar.entry, + version=sidecar.version, + role=cast(SidecarRole, sidecar.role.value), + class_=cast(SidecarClass, sidecar.class_.value), + state=sidecar.state, + name=sidecar.name, + address=sidecar.address if isinstance(sidecar.address, str) else None, + ports=ports, + last_error=sidecar.last_error if isinstance(sidecar.last_error, str) else None, + ) + + +def from_client_sidecars( + sidecars: Union[Unset, List[ClientSidecarInfo]], +) -> List[SidecarInfo]: + if isinstance(sidecars, Unset): + return [] + + return [_from_client_sidecar(sidecar) for sidecar in sidecars] + + +def sidecar_api_exception(res: Any) -> Optional[Exception]: + """Map a ``sidecar_*`` rejection, or ``None`` for any other response. + + Sidecar validation failures are 400 with a ``sidecar_*`` semantic code — + ``sidecar_unknown_entry``, ``sidecar_deprecated_entry``, ``sidecar_limit``, + ``sidecar_one_proxy``, ``sidecar_config_invalid``, ``sidecar_secret_missing``, + ``sidecar_rule_collision``, ``sidecar_egress_conflict``, ``sidecar_flag_off`` — + and a sidecar that did not start is ``sidecar_failed`` naming the entry. On + resume, ``sidecar_version_unavailable`` (409: the catalog version the sidecar + was snapshotted with has been removed; the sandbox stays paused) and + ``sidecar_snapshot_mismatch`` (500: a stored sidecar snapshot has no matching + declaration) stay :class:`SandboxException` — the SDK has no conflict type. + The code stays in the message so callers can tell them apart. + """ + try: + body = json.loads(res.content) if res.content else {} + except ValueError: + # JSONDecodeError and UnicodeDecodeError, for a non-JSON or non-UTF-8 body + return None + if not isinstance(body, dict): + return None + + code = body.get("error_code") + if not isinstance(code, str) or not code.startswith("sidecar_"): + return None + + message = f"{code}: {body.get('message', res.status_code)}" + if res.status_code == 400: + return InvalidArgumentException(message, status_code=400) + return SandboxException(message, status_code=res.status_code) + + def build_iam_config( iam: Optional[SandboxIamOpts], ) -> Optional[ClientSandboxIam]: @@ -1053,6 +1292,8 @@ class SandboxInfo: """Sandbox lifecycle configuration.""" volume_mounts: List[Dict[str, str]] = field(default_factory=list) """Volume mounts for the sandbox.""" + sidecars: List[SidecarInfo] = field(default_factory=list) + """Sidecars attached to the sandbox, empty when there are none.""" @classmethod def _from_sandbox_data( @@ -1083,6 +1324,7 @@ def _from_sandbox_data( ] if not isinstance(sandbox.volume_mounts, Unset) else [], + sidecars=from_client_sidecars(sandbox.sidecars), allow_internet_access=allow_internet_access, network=network, lifecycle=lifecycle, diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index da6bdc1f3b..70102c9440 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -28,6 +28,7 @@ SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, + SidecarAttachment, SandboxNetworkUpdate, SandboxOnResume, SnapshotInfo, @@ -183,6 +184,8 @@ async def create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxAsyncVolumeMount] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -201,6 +204,7 @@ async def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "valkey"}`` for a service sidecar the sandbox reaches at ``valkey.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox @@ -234,6 +238,7 @@ async def create( iam=iam, lifecycle=lifecycle, volume_mounts=transformed_mounts, + sidecars=sidecars, logger=logger, **opts, ) @@ -1158,6 +1163,8 @@ async def _create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) @@ -1183,6 +1190,7 @@ async def _create( iam=iam, lifecycle=lifecycle, volume_mounts=volume_mounts, + sidecars=sidecars, logger=logger, **params, ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 8fd0dcb3fc..cbd63b5d24 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -57,12 +57,15 @@ SandboxNetworkOpts, SandboxNetworkUpdate, SandboxOnResume, + SidecarAttachment, resolve_connect_memory, SandboxQuery, SnapshotInfo, build_iam_config, build_lifecycle_config, build_network_config, + build_sidecars_body, + sidecar_api_exception, ) from e2b.sandbox_async.paginator import AsyncSandboxPaginator @@ -204,7 +207,7 @@ async def _cls_update_network( raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) @classmethod async def _create_sandbox( @@ -220,6 +223,7 @@ async def _create_sandbox( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: @@ -232,6 +236,7 @@ async def _create_sandbox( # against the workload tokens this request registers. iam_body = build_iam_config(iam) network_body = build_network_config(network, iam_body) + sidecars_body = build_sidecars_body(sidecars) body = NewSandbox( template_id=template, auto_pause=lifecycle_body.auto_pause, @@ -246,6 +251,7 @@ async def _create_sandbox( network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, + sidecars=sidecars_body if sidecars_body else UNSET, ) api_client = get_api_client(config) @@ -255,7 +261,7 @@ async def _create_sandbox( ) if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) if res.parsed is None: raise Exception("Body of the request is None") @@ -551,7 +557,7 @@ async def _cls_connect( raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) # Check if res.parse is Error if isinstance(res.parsed, Error): diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 3a9e172f34..91bbc40c7a 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -27,6 +27,7 @@ SandboxLifecycle, SandboxMetrics, SandboxNetworkOpts, + SidecarAttachment, SandboxNetworkUpdate, SandboxOnResume, SnapshotInfo, @@ -179,6 +180,8 @@ def create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[SandboxVolumeMount] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -197,6 +200,7 @@ def create( :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names + :param sidecars: Sidecar microVMs to attach to the sandbox from the E2B catalog — at most four, at most one with the proxy role. Each is a :class:`SidecarAttachment`: ``{"entry": "valkey"}`` for a service sidecar the sandbox reaches at ``valkey.sidecar.e2b.local``, or ``{"entry": "iron-proxy", "secrets": {"": "${e2b.secrets.}"}}`` for the proxy sidecar that substitutes the real secret value on egress so it never enters the sandbox. A sidecar follows the sandbox's lifecycle (paused, snapshotted, resumed as it was, forked and terminated with it; a crashed sidecar is restarted once and then reported ``"failed"`` while the sandbox keeps running) and needs the team's ``sandbox-sidecars`` feature :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. :return: A Sandbox instance for the new sandbox @@ -230,6 +234,7 @@ def create( iam=iam, lifecycle=lifecycle, volume_mounts=transformed_mounts, + sidecars=sidecars, logger=logger, **opts, ) @@ -1154,6 +1159,8 @@ def _create( lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[list] = None, logger: Optional[logging.Logger] = None, + *, + sidecars: Optional[List[SidecarAttachment]] = None, **opts: Unpack[ApiParams], ) -> Self: params = cls._resolve_api_params(**opts) @@ -1179,6 +1186,7 @@ def _create( iam=iam, lifecycle=lifecycle, volume_mounts=volume_mounts, + sidecars=sidecars, logger=logger, **params, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 61c668ce04..f78173d15e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -56,12 +56,15 @@ SandboxNetworkOpts, SandboxNetworkUpdate, SandboxOnResume, + SidecarAttachment, resolve_connect_memory, SandboxQuery, SnapshotInfo, build_iam_config, build_lifecycle_config, build_network_config, + build_sidecars_body, + sidecar_api_exception, ) from e2b.sandbox_sync.paginator import SandboxPaginator, get_api_client @@ -203,7 +206,7 @@ def _cls_update_network( raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) @classmethod def _create_sandbox( @@ -219,6 +222,7 @@ def _create_sandbox( iam: Optional[SandboxIamOpts] = None, lifecycle: Optional[SandboxLifecycle] = None, volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None, + sidecars: Optional[List[SidecarAttachment]] = None, logger: Optional[logging.Logger] = None, **opts: Unpack[ApiParams], ) -> SandboxCreateResponse: @@ -231,6 +235,7 @@ def _create_sandbox( # against the workload tokens this request registers. iam_body = build_iam_config(iam) network_body = build_network_config(network, iam_body) + sidecars_body = build_sidecars_body(sidecars) body = NewSandbox( template_id=template, auto_pause=lifecycle_body.auto_pause, @@ -245,6 +250,7 @@ def _create_sandbox( network=SandboxNetworkConfig(**network_body) if network_body else UNSET, iam=iam_body or UNSET, volume_mounts=volume_mounts if volume_mounts else UNSET, + sidecars=sidecars_body if sidecars_body else UNSET, ) api_client = get_api_client(config) @@ -254,7 +260,7 @@ def _create_sandbox( ) if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) if res.parsed is None: raise Exception("Body of the request is None") @@ -365,7 +371,7 @@ def _cls_connect( raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found") if res.status_code >= 300: - raise handle_api_exception(res) + raise sidecar_api_exception(res) or handle_api_exception(res) if isinstance(res.parsed, Error): raise SandboxException(f"{res.parsed.message}: Request failed") diff --git a/packages/python-sdk/tests/shared/sandbox/test_sidecars.py b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py new file mode 100644 index 0000000000..340a075af7 --- /dev/null +++ b/packages/python-sdk/tests/shared/sandbox/test_sidecars.py @@ -0,0 +1,559 @@ +import logging +from types import SimpleNamespace +from typing import Any, Dict, List, cast +from unittest.mock import AsyncMock, Mock + +import pytest + +from e2b import AsyncSandbox, Sandbox, SandboxInfo, SidecarInfo +from e2b.api.client.api.sandboxes import ( + post_sandboxes, + post_sandboxes_sandbox_id_connect, + put_sandboxes_sandbox_id_network, +) +from e2b.api.client.models import ListedSandbox, SandboxDetail +from e2b.api.client.models import Sandbox as SandboxModel +from e2b.exceptions import ( + InvalidArgumentException, + SandboxException, + SandboxNotFoundException, +) +from e2b.sandbox.sandbox_api import build_sidecars_body, sidecar_api_exception + +VALKEY_INFO: Dict[str, Any] = { + "entry": "valkey", + "version": "7.4.1", + "role": "service", + "class": "stateful", + "state": "running", + "name": "valkey.sidecar.e2b.local", + "address": "169.254.0.25", + "ports": [6379], +} + +FAILED_PROXY_INFO: Dict[str, Any] = { + "entry": "iron-proxy", + "version": "0.4.1", + "role": "proxy", + "class": "stateful", + "state": "failed", + "name": "iron-proxy.sidecar.e2b.local", + "lastError": "readiness probe timed out", +} + +SANDBOX_DETAIL: Dict[str, Any] = { + "sandboxID": "sbx-test", + "templateID": "template-id", + "clientID": "client-id", + "envdVersion": "0.2.4", + "startedAt": "2026-01-01T00:00:00Z", + "endAt": "2026-01-01T01:00:00Z", + "state": "running", + "cpuCount": 2, + "memoryMB": 512, + "diskSizeMB": 1024, +} + + +def _response(status_code: int, content: bytes = b"", parsed=None): + return SimpleNamespace( + status_code=status_code, content=content, headers={}, parsed=parsed + ) + + +def _created_sandbox(): + return _response( + 200, + parsed=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="sbx-test", + template_id="template-id", + ), + ) + + +def _sync_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + Sandbox.create(api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_request_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: + request = AsyncMock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) + + await AsyncSandbox.create(api_key=api_key, **kwargs) + + return request.call_args.kwargs["body"].to_dict() + + +SIDECARS: List[Any] = [ + {"entry": "valkey"}, + { + "entry": "iron-proxy", + "version": "0.4.1", + "config": {"allow": ["api.openai.com"]}, + "secrets": {"upstream": "${e2b.secrets.openai-key}"}, + }, +] + +SIDECARS_WIRE = [ + {"entry": "valkey"}, + { + "entry": "iron-proxy", + "version": "0.4.1", + "config": {"allow": ["api.openai.com"]}, + "secrets": {"upstream": "${e2b.secrets.openai-key}"}, + }, +] + + +def test_create_sends_the_sidecars(monkeypatch, test_api_key): + body = _sync_request_body(monkeypatch, test_api_key, sidecars=SIDECARS) + + assert body["sidecars"] == SIDECARS_WIRE + + +async def test_async_create_sends_the_sidecars(monkeypatch, test_api_key): + body = await _async_request_body(monkeypatch, test_api_key, sidecars=SIDECARS) + + assert body["sidecars"] == SIDECARS_WIRE + + +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({}, id="not-provided"), + pytest.param({"sidecars": None}, id="none"), + pytest.param({"sidecars": []}, id="empty-list"), + ], +) +def test_create_omits_sidecars_when_there_are_none(monkeypatch, test_api_key, kwargs): + body = _sync_request_body(monkeypatch, test_api_key, **kwargs) + + assert "sidecars" not in body + + +async def test_async_create_omits_an_empty_sidecar_list(monkeypatch, test_api_key): + body = await _async_request_body(monkeypatch, test_api_key, sidecars=[]) + + assert "sidecars" not in body + + +def test_create_strips_unknown_sidecar_keys(): + # An untyped caller can copy an extra key out of a config file; the API + # rejects unknown properties. + body = build_sidecars_body(cast(Any, [{"entry": "valkey", "image": "valkey:7"}])) + + assert body is not None + assert [s.to_dict() for s in body] == [{"entry": "valkey"}] + + +@pytest.mark.parametrize( + "sidecars", + [ + pytest.param([{"version": "7.4.1"}], id="missing-entry"), + pytest.param([{"entry": 6379}], id="non-string-entry"), + pytest.param(["valkey"], id="string-item"), + pytest.param({"entry": "valkey"}, id="dict-instead-of-list"), + pytest.param("valkey", id="string"), + ], +) +def test_create_rejects_a_malformed_sidecar_list(monkeypatch, test_api_key, sidecars): + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException, match="sidecars"): + Sandbox.create(api_key=test_api_key, sidecars=cast(Any, sidecars)) + + request.assert_not_called() + + +@pytest.mark.parametrize( + "sidecar, field", + [ + pytest.param( + {"entry": "valkey", "config": ["maxmemory"]}, "config", id="config-list" + ), + pytest.param( + {"entry": "valkey", "config": "maxmemory=64mb"}, "config", id="config-str" + ), + pytest.param( + {"entry": "iron-proxy", "secrets": ["upstream"]}, + "secrets", + id="secrets-list", + ), + pytest.param( + {"entry": "iron-proxy", "secrets": {"upstream": 1}}, + "secrets", + id="secrets-non-str-value", + ), + pytest.param( + {"entry": "iron-proxy", "secrets": {1: "x"}}, + "secrets", + id="secrets-non-str-key", + ), + ], +) +def test_create_rejects_a_malformed_sidecar_config_or_secrets_by_name(sidecar, field): + # dict() on a bad value used to escape as a bare ValueError that named nothing. + with pytest.raises(InvalidArgumentException, match=rf"sidecars\[0\]\.{field}"): + build_sidecars_body(cast(Any, [sidecar])) + + +def _expected_infos() -> List[SidecarInfo]: + return [ + SidecarInfo( + entry="valkey", + version="7.4.1", + role="service", + class_="stateful", + state="running", + name="valkey.sidecar.e2b.local", + address="169.254.0.25", + ports=[6379], + ), + SidecarInfo( + entry="iron-proxy", + version="0.4.1", + role="proxy", + class_="stateful", + state="failed", + name="iron-proxy.sidecar.e2b.local", + last_error="readiness probe timed out", + ), + ] + + +def test_info_returns_the_sidecars_with_their_state(): + detail = SandboxDetail.from_dict( + {**SANDBOX_DETAIL, "sidecars": [VALKEY_INFO, FAILED_PROXY_INFO]} + ) + + info = SandboxInfo._from_sandbox_detail(detail) + + assert info.sidecars == _expected_infos() + + +def test_info_passes_through_a_state_value_the_sdk_does_not_know(): + detail = SandboxDetail.from_dict( + {**SANDBOX_DETAIL, "sidecars": [{**VALKEY_INFO, "state": "restarting"}]} + ) + + info = SandboxInfo._from_sandbox_detail(detail) + + assert info.sidecars[0].state == "restarting" + + +def test_info_treats_null_optional_fields_as_absent(): + # A wire `null` is neither Unset nor a value; it must not raise inside get_info()/list(). + detail = SandboxDetail.from_dict( + { + **SANDBOX_DETAIL, + "sidecars": [ + {**VALKEY_INFO, "ports": None, "address": None, "lastError": None} + ], + } + ) + + [info] = SandboxInfo._from_sandbox_detail(detail).sidecars + + assert info.ports == [] + assert info.address is None + assert info.last_error is None + + +def test_info_returns_an_empty_sidecar_list_when_the_api_sends_none(): + info = SandboxInfo._from_sandbox_detail(SandboxDetail.from_dict(SANDBOX_DETAIL)) + + assert info.sidecars == [] + + +def test_list_returns_the_sidecars_of_each_sandbox(): + listed = ListedSandbox.from_dict({**SANDBOX_DETAIL, "sidecars": [VALKEY_INFO]}) + + info = SandboxInfo._from_listed_sandbox(listed) + + assert info.sidecars == _expected_infos()[:1] + + +def test_a_sidecar_400_is_an_argument_error_with_the_code_preserved( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 400, + b'{"code":400,"error_code":"sidecar_unknown_entry",' + b'"message":"unknown sidecar entry \\"memcached\\""}', + ) + ) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException) as excinfo: + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "memcached"}]) + + assert excinfo.value.status_code == 400 + assert "sidecar_unknown_entry" in str(excinfo.value) + assert "memcached" in str(excinfo.value) + + +async def test_async_sidecar_400_is_an_argument_error(monkeypatch, test_api_key): + request = AsyncMock( + return_value=_response( + 400, b'{"code":400,"error_code":"sidecar_flag_off","message":"off"}' + ) + ) + monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) + + with pytest.raises(InvalidArgumentException, match="sidecar_flag_off"): + await AsyncSandbox.create(api_key=test_api_key, sidecars=[{"entry": "valkey"}]) + + +def test_sidecar_failed_keeps_the_entry_name_and_is_not_an_argument_error( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 500, + b'{"code":500,"error_code":"sidecar_failed",' + b'"message":"sidecar \\"valkey\\" failed to become ready"}', + ) + ) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + with pytest.raises(SandboxException) as excinfo: + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "valkey"}]) + + assert not isinstance(excinfo.value, InvalidArgumentException) + assert excinfo.value.status_code == 500 + assert "sidecar_failed" in str(excinfo.value) + assert "valkey" in str(excinfo.value) + + +@pytest.mark.parametrize( + "code", + [ + "sidecar_unknown_entry", + "sidecar_deprecated_entry", + "sidecar_limit", + "sidecar_one_proxy", + "sidecar_config_invalid", + "sidecar_secret_missing", + "sidecar_rule_collision", + "sidecar_egress_conflict", + "sidecar_flag_off", + ], +) +def test_every_400_sidecar_code_is_an_argument_error(code): + err = sidecar_api_exception( + _response( + 400, f'{{"code":400,"error_code":"{code}","message":"rejected"}}'.encode() + ) + ) + + assert isinstance(err, InvalidArgumentException) + assert err.status_code == 400 + assert str(err) == f"{code}: rejected" + + +@pytest.mark.parametrize( + "content", + [ + pytest.param(b'{"code":400,"message":"invalid template"}', id="no-code"), + pytest.param(b'{"error_code":"sandbox_create_failed"}', id="other-code"), + pytest.param(b'{"error_code":"SIDECAR_UNKNOWN_ENTRY"}', id="uppercase"), + pytest.param(b"not json", id="not-json"), + pytest.param(b"", id="empty"), + pytest.param(b"[1]", id="not-an-object"), + pytest.param(b"\xff\xfe{", id="not-utf8"), + ], +) +def test_other_errors_keep_the_generic_mapping(content): + assert sidecar_api_exception(_response(400, content)) is None + + +@pytest.mark.parametrize( + "code, status", + [ + ("sidecar_version_unavailable", 409), + ("sidecar_snapshot_mismatch", 500), + ], +) +def test_resume_codes_stay_sandbox_exceptions_with_the_code_and_status(code, status): + err = sidecar_api_exception( + _response( + status, + f'{{"code":{status},"error_code":"{code}","message":"resume"}}'.encode(), + ) + ) + + assert isinstance(err, SandboxException) + assert not isinstance(err, InvalidArgumentException) + assert err.status_code == status + assert str(err) == f"{code}: resume" + + +def test_connect_surfaces_a_sidecar_version_unavailable_conflict( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 409, + b'{"code":409,"error_code":"sidecar_version_unavailable",' + b'"message":"catalog version valkey@7.4.0 is no longer available"}', + ) + ) + monkeypatch.setattr(post_sandboxes_sandbox_id_connect, "sync_detailed", request) + + with pytest.raises(SandboxException) as excinfo: + Sandbox.connect("sbx-test", api_key=test_api_key) + + assert not isinstance(excinfo.value, InvalidArgumentException) + assert excinfo.value.status_code == 409 + assert "sidecar_version_unavailable" in str(excinfo.value) + assert "valkey@7.4.0" in str(excinfo.value) + + +def test_update_network_surfaces_a_rule_collision_as_an_argument_error( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 400, + b'{"code":400,"error_code":"sidecar_rule_collision",' + b'"message":"api.openai.com is routed through the iron-proxy sidecar"}', + ) + ) + monkeypatch.setattr(put_sandboxes_sandbox_id_network, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException, match="sidecar_rule_collision"): + Sandbox.update_network( + "sbx-test", {"allow_out": ["api.openai.com"]}, api_key=test_api_key + ) + + +def test_update_network_404_still_wins_over_the_sidecar_mapping( + monkeypatch, test_api_key +): + request = Mock( + return_value=_response( + 404, b'{"code":404,"error_code":"sidecar_x","message":"gone"}' + ) + ) + monkeypatch.setattr(put_sandboxes_sandbox_id_network, "sync_detailed", request) + + with pytest.raises(SandboxNotFoundException): + Sandbox.update_network("sbx-test", {}, api_key=test_api_key) + + +def _create_response(): + return SimpleNamespace( + sandbox_id="sbx-test", + sandbox_domain=None, + envd_version="0.2.4", + envd_access_token=None, + traffic_access_token=None, + ) + + +def test_create_keeps_logger_positional_and_sidecars_keyword_only( + monkeypatch, test_api_key +): + from e2b.sandbox_sync.sandbox_api import SandboxApi + + create_sandbox = Mock(return_value=_create_response()) + monkeypatch.setattr(SandboxApi, "_create_sandbox", create_sandbox) + logger = logging.getLogger("sidecar-test") + + # The twelve positional parameters create() had before sidecars existed. + Sandbox.create( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + api_key=test_api_key, + ) + + assert create_sandbox.call_args.kwargs["logger"] is logger + assert create_sandbox.call_args.kwargs["sidecars"] is None + + Sandbox.create(api_key=test_api_key, sidecars=[{"entry": "valkey"}]) + assert create_sandbox.call_args.kwargs["sidecars"] == [{"entry": "valkey"}] + + # cast: the extra positional argument is the point; ty would reject it statically. + with pytest.raises(TypeError): + cast(Any, Sandbox.create)( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + [{"entry": "valkey"}], + api_key=test_api_key, + ) + + +async def test_async_create_keeps_logger_positional_and_sidecars_keyword_only( + monkeypatch, test_api_key +): + from e2b.sandbox_async.sandbox_api import SandboxApi + + create_sandbox = AsyncMock(return_value=_create_response()) + monkeypatch.setattr(SandboxApi, "_create_sandbox", create_sandbox) + logger = logging.getLogger("sidecar-test") + + await AsyncSandbox.create( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + api_key=test_api_key, + ) + + assert create_sandbox.call_args.kwargs["logger"] is logger + assert create_sandbox.call_args.kwargs["sidecars"] is None + + with pytest.raises(TypeError): + await cast(Any, AsyncSandbox.create)( + "template-id", + 60, + None, + None, + True, + True, + None, + None, + None, + None, + None, + logger, + [{"entry": "valkey"}], + api_key=test_api_key, + ) diff --git a/spec/openapi.yml b/spec/openapi.yml index e7787b451f..adeb350165 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -737,6 +737,75 @@ components: - name - path + SidecarAttachment: + type: object + description: A sidecar microVM to attach to the sandbox, declared from the E2B sidecar catalog. + required: + - entry + properties: + entry: + type: string + description: Catalog entry name (for example "iron-proxy" or "valkey"). The sandbox reaches the sidecar at "{entry}.sidecar.e2b.local". + version: + type: string + description: Catalog entry version. Defaults to the entry's current version. + config: + type: object + description: Entry-specific configuration, validated against the entry's schema. String values may reference secrets as "${e2b.secrets.}". + additionalProperties: true + secrets: + type: object + description: Secret slots the entry declares, keyed by slot name, each holding a secret reference the platform resolves at injection time. The secret value never enters the sandbox. + additionalProperties: + type: string + + SidecarInfo: + type: object + description: A sidecar attached to the sandbox and its current state. + required: + - entry + - version + - role + - class + - state + - name + properties: + entry: + type: string + description: Catalog entry name + version: + type: string + description: Catalog entry version + role: + type: string + enum: + - proxy + - service + description: Role of the sidecar + class: + type: string + enum: + - ephemeral + - stateful + description: Lifecycle class of the sidecar + state: + type: string + description: Current state of the sidecar. Not a closed set; current values are starting, running, failed and stopped. + name: + type: string + description: Name the sandbox reaches the sidecar at ("{entry}.sidecar.e2b.local") + address: + type: string + description: Address of the sidecar inside the sandbox network + ports: + type: array + description: Ports the sidecar listens on + items: + type: integer + lastError: + type: string + description: Last error of the sidecar, set when the state is failed + Sandbox: required: - templateID @@ -770,6 +839,10 @@ components: type: string nullable: true description: Base domain where the sandbox traffic is accessible + sidecars: + type: array + items: + $ref: "#/components/schemas/SidecarInfo" SandboxDetail: required: @@ -836,6 +909,10 @@ components: type: array items: $ref: "#/components/schemas/SandboxVolumeMount" + sidecars: + type: array + items: + $ref: "#/components/schemas/SidecarInfo" ListedSandbox: required: @@ -887,6 +964,10 @@ components: type: array items: $ref: "#/components/schemas/SandboxVolumeMount" + sidecars: + type: array + items: + $ref: "#/components/schemas/SidecarInfo" SandboxesWithMetrics: required: @@ -948,6 +1029,12 @@ components: type: array items: $ref: "#/components/schemas/SandboxVolumeMount" + sidecars: + type: array + description: Sidecar microVMs to attach to the sandbox, at most four, at most one with the proxy role. Requires the team's sandbox-sidecars feature. + maxItems: 4 + items: + $ref: "#/components/schemas/SidecarAttachment" SandboxIam: type: object