Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-sandbox-sidecars.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/sandbox-sidecars.md
Original file line number Diff line number Diff line change
@@ -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.<name>}` 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.
42 changes: 39 additions & 3 deletions packages/cli/src/commands/sandbox/info.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
sandboxId: 'Sandbox ID',
Expand All @@ -17,6 +18,7 @@ const fieldLabels: Partial<Record<string, string>> = {
allowInternetAccess: 'Internet access',
lifecycle: 'Lifecycle',
network: 'Network',
sidecars: 'Sidecars',
sandboxDomain: 'Sandbox domain',
metadata: 'Metadata',
}
Expand All @@ -34,6 +36,7 @@ const fieldOrder = [
'allowInternetAccess',
'lifecycle',
'network',
'sidecars',
'sandboxDomain',
'metadata',
]
Expand Down Expand Up @@ -71,7 +74,7 @@ export const infoCommand = new commander.Command('info')
}
})

function renderPrettyInfo(info: Record<string, unknown>) {
export function renderPrettyInfo(info: Record<string, unknown>) {
console.log(
`\nSandbox info for ${asBold(String(info.sandboxId ?? 'unknown'))}:`
)
Expand All @@ -87,8 +90,15 @@ function renderPrettyInfo(info: Record<string, unknown>) {
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
Expand All @@ -105,6 +115,32 @@ function renderPrettyInfo(info: Record<string, unknown>) {
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()
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/commands/sandbox/list.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 },
])
}
Expand Down
32 changes: 20 additions & 12 deletions packages/cli/src/utils/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export interface Column<T> {
* ```
*/
export function renderTable<T>(items: T[], columns: Column<T>[]) {
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<T>(items: T[], columns: Column<T>[]): string[] {
const headers = columns.map((column) => column.header.toUpperCase())
const rows = items.map((item) =>
columns.map((column) => column.value(item) ?? '')
Expand All @@ -36,16 +46,14 @@ export function renderTable<T>(items: T[], columns: Column<T>[]) {
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()
)
}
82 changes: 82 additions & 0 deletions packages/cli/tests/commands/sandbox/info.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
33 changes: 33 additions & 0 deletions packages/cli/tests/commands/sandbox/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SandboxInfo } from 'e2b'

import {
buildTableRows,
formatSidecars,
sortSandboxes,
} from '../../../src/commands/sandbox/list'

Expand Down Expand Up @@ -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'))
Expand Down
23 changes: 17 additions & 6 deletions packages/cli/tests/utils/table.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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()

Expand All @@ -64,10 +79,6 @@ describe('renderTable', () => {
]
)

expect(lines).toEqual([
'NAME STATE',
'日本語 ok',
'abcdef ok',
])
expect(lines).toEqual(['NAME STATE', '日本語 ok', 'abcdef ok'])
})
})
Loading
Loading