Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions .changeset/sandbox-iam-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'e2b': minor
'@e2b/python-sdk': minor
---

Add the `iam` option to `Sandbox.create` for configuring sandbox workload identity, and a `Secret` class with an `iamToken` / `iam_token` method for defining the workload tokens. Passing a non-empty `tokens` map (name → `{ audience, tokenType }`) enables workload identity for the sandbox:

```ts
import { Sandbox, Secret } from 'e2b'

const sandbox = await Sandbox.create({
iam: {
tokens: {
aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
},
},
})
```

```python
from e2b import Sandbox, Secret

sandbox = Sandbox.create(
iam={
"tokens": {
"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"),
},
},
)
```

Plain `{ audience, tokenType }` objects (`{"audience": ..., "token_type": ...}` dicts in Python) are accepted as token values too.
5 changes: 5 additions & 0 deletions packages/js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export type {
SandboxState,
SandboxListOpts,
SandboxPaginator,
SandboxIamOpts,
SandboxIamToken,
SandboxIamTokenType,
SandboxNetworkOpts,
SandboxNetworkInfo,
SandboxNetworkSelector,
Expand All @@ -82,6 +85,8 @@ export type {

export type { McpServer } from './sandbox/mcp'

export { Secret } from './secret'

export { ALL_TRAFFIC } from './sandbox/network'

export type {
Expand Down
90 changes: 90 additions & 0 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,40 @@ export type SandboxNetworkUpdate = {
allowInternetAccess?: boolean
}

/**
* Workload token type. `'JWT-SVID'` is the only type the API accepts in this
* version; the set is defined server-side and may grow, so any string is
* allowed.
*/
export type SandboxIamTokenType = 'JWT-SVID' | (string & {})

/**
* Workload token definition for sandbox workload identity.
*/
export interface SandboxIamToken {
/**
* Audience of the workload token, stored exactly as provided.
*/
audience: string

/**
* Workload token type.
*/
tokenType: SandboxIamTokenType
}

/**
* Sandbox workload identity configuration. A non-empty `tokens` map enables
* workload identity for the sandbox.
*/
export interface SandboxIamOpts {
/**
* Named workload-token definitions, keyed by a caller-chosen token name.
* Values can be created with `Secret.iamToken()`.
*/
tokens?: Record<string, SandboxIamToken>
}

/**
* What happens when the sandbox timeout is reached. Either the bare action
* (`'pause'` / `'kill'`), or an object form that also controls the pause
Expand Down Expand Up @@ -405,6 +439,26 @@ export interface SandboxOpts extends ConnectionOpts {
*/
network?: SandboxNetworkOpts

/**
* Sandbox workload identity configuration. Providing a non-empty
* `tokens` map enables workload identity for the sandbox.
*
* @example
* ```ts
* const sandbox = await Sandbox.create({
* iam: {
* tokens: {
* aws: Secret.iamToken({
* audience: 'sts.amazonaws.com',
* tokenType: 'JWT-SVID',
* }),
* },
* },
* })
* ```
*/
iam?: SandboxIamOpts

/**
* Volume mounts for the sandbox.
*
Expand Down Expand Up @@ -738,6 +792,41 @@ function buildNetworkBody(
}
}

function buildIamBody(
iam: SandboxIamOpts | undefined
): components['schemas']['SandboxIam'] | undefined {
// Rebuild the body from the known fields so stray properties on the
// caller's objects never reach the wire and later mutations of the
// caller's map cannot alter the in-flight request.
const tokens: components['schemas']['SandboxIamTokens'] = {}
for (const [name, token] of Object.entries(iam?.tokens ?? {})) {
// Untyped callers can leave holes in the map (conditionally included
// tokens); those don't count toward a non-empty config.
if (!token) {
continue
}

if (
typeof token.audience !== 'string' ||
typeof token.tokenType !== 'string'
) {
throw new InvalidArgumentError(
`iam token '${name}' must have string 'audience' and 'tokenType' properties.`
)
}

tokens[name] = { audience: token.audience, tokenType: token.tokenType }
}

// Only a non-empty tokens map enables workload identity, so an empty
// iam config is omitted from the payload entirely.
if (Object.keys(tokens).length === 0) {
return undefined
}

return { tokens }
}

function buildNetworkUpdateBody(
network: SandboxNetworkUpdate
): components['schemas']['SandboxNetworkUpdateConfig'] {
Expand Down Expand Up @@ -1211,6 +1300,7 @@ export class SandboxApi {
secure: opts?.secure ?? true,
allow_internet_access: opts?.allowInternetAccess ?? true,
network: buildNetworkBody(opts?.network),
iam: buildIamBody(opts?.iam),
autoPause: action === 'pause',
autoPauseMemory: action === 'pause' ? keepMemory : undefined,
autoResume: { enabled: autoResume },
Expand Down
32 changes: 32 additions & 0 deletions packages/js-sdk/src/secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { SandboxIamToken } from './sandbox/sandboxApi'

/**
* Secrets and workload identity helpers.
*/
export class Secret {
/**
* Define a workload identity token to pass to `iam.tokens` when creating
* a sandbox.
*
* @param token workload token definition.
*
* @returns a token definition passable to `iam.tokens`.
*
* @example
* ```ts
* const sandbox = await Sandbox.create({
* iam: {
* tokens: {
* aws: Secret.iamToken({
* audience: 'sts.amazonaws.com',
* tokenType: 'JWT-SVID',
* }),
* },
* },
* })
* ```
*/
static iamToken(token: SandboxIamToken): SandboxIamToken {
return { ...token }
}
}
130 changes: 130 additions & 0 deletions packages/js-sdk/tests/sandbox/iam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

import { InvalidArgumentError, Sandbox, Secret } from '../../src'
import { TEST_API_KEY, apiUrl } from '../setup'

let lastCreateBody: Record<string, unknown> | undefined

const server = setupServer(
http.post(apiUrl('/sandboxes'), async ({ request }) => {
lastCreateBody = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
sandboxID: 'test-sandbox-id',
templateID: 'base',
envdVersion: '0.2.4',
})
})
)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

afterAll(() => server.close())

afterEach(() => {
lastCreateBody = undefined
server.resetHandlers()
})

test('Sandbox.create sends iam tokens in the request body', async () => {
await Sandbox.create('base', {
apiKey: TEST_API_KEY,
iam: {
tokens: {
aws: { audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' },
},
},
})

expect(lastCreateBody?.iam).toEqual({
tokens: {
aws: { audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' },
},
})
})

test('Sandbox.create sends Secret.iamToken tokens in the request body', async () => {
await Sandbox.create('base', {
apiKey: TEST_API_KEY,
iam: {
tokens: {
aws: Secret.iamToken({
audience: 'sts.amazonaws.com',
tokenType: 'JWT-SVID',
}),
},
},
})

expect(lastCreateBody?.iam).toEqual({
tokens: {
aws: { audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' },
},
})
})

test('Sandbox.create omits iam from the request body when not provided', async () => {
await Sandbox.create('base', { apiKey: TEST_API_KEY })

expect(lastCreateBody).toBeDefined()
expect(lastCreateBody).not.toHaveProperty('iam')
})

test('Sandbox.create omits an empty iam config from the request body', async () => {
await Sandbox.create('base', { apiKey: TEST_API_KEY, iam: {} })

expect(lastCreateBody).toBeDefined()
expect(lastCreateBody).not.toHaveProperty('iam')

await Sandbox.create('base', { apiKey: TEST_API_KEY, iam: { tokens: {} } })

expect(lastCreateBody).toBeDefined()
expect(lastCreateBody).not.toHaveProperty('iam')
})

test('Sandbox.create treats a tokens map with only undefined values as empty', async () => {
await Sandbox.create('base', {
apiKey: TEST_API_KEY,
iam: {
// Untyped callers can build the map conditionally, leaving holes.
tokens: { aws: undefined as never },
},
})

expect(lastCreateBody).toBeDefined()
expect(lastCreateBody).not.toHaveProperty('iam')
})

test('Sandbox.create strips unknown token properties from the request body', async () => {
await Sandbox.create('base', {
apiKey: TEST_API_KEY,
iam: {
tokens: {
aws: {
audience: 'sts.amazonaws.com',
tokenType: 'JWT-SVID',
filePath: '/run/token',
} as never,
},
},
})

expect(lastCreateBody?.iam).toEqual({
tokens: {
aws: { audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' },
},
})
})

test('Sandbox.create rejects a token missing audience or tokenType', async () => {
await expect(
Sandbox.create('base', {
apiKey: TEST_API_KEY,
iam: {
// The wire-format casing an untyped caller might copy from a payload.
tokens: { aws: { audience: 'sts.amazonaws.com' } as never },
},
})
).rejects.toThrowError(InvalidArgumentError)
})
9 changes: 9 additions & 0 deletions packages/python-sdk/e2b/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
GitHubMcpServer,
GitHubMcpServerConfig,
McpServer,
SandboxIamOpts,
SandboxIamToken,
SandboxIamTokenType,
SandboxInfo,
SandboxInfoLifecycle,
SandboxMetrics,
Expand All @@ -98,6 +101,7 @@
from .sandbox_async.main import AsyncSandbox
from .sandbox_async.paginator import AsyncSandboxPaginator, AsyncSnapshotPaginator
from .sandbox_async.utils import OutputHandler
from .secret import Secret
from .sandbox_sync.commands.command_handle import CommandHandle
from .sandbox_sync.filesystem.watch_handle import WatchHandle
from .sandbox_sync.main import Sandbox
Expand Down Expand Up @@ -204,6 +208,11 @@
"SandboxLifecycle",
"SandboxOnTimeout",
"ALL_TRAFFIC",
# IAM
"SandboxIamOpts",
"SandboxIamToken",
"SandboxIamTokenType",
"Secret",
# Snapshot
"SnapshotInfo",
"SnapshotPaginator",
Expand Down
Loading
Loading