feat(sdk): resolve iam token placeholders in network transform callbacks (#1616)
Stacked on #1606 (`iam-sdk-feature`) — merge that one first. This is the second half of SDK-245: it makes the workload tokens registered by `Sandbox.create`'s `iam` option usable, by letting a network rule's `transform` be a **callback** that receives placeholder strings the egress proxy resolves per request. `iam.tokens.aws` is the literal string `${e2b.identity.tokens.aws}` (the frozen backend spelling — a placeholder can only select a persisted named token, never an inline audience or claim). The SDK never resolves it: the wire payload carries the placeholder and the proxy substitutes a freshly minted JWT-SVID when it forwards the request, so the token value never reaches SDK-side code or the sandbox. Referencing a name that isn't registered in `iam.tokens` fails with `InvalidArgumentError` / `InvalidArgumentException` listing the names that are — the proxy never turns an unregistered name into a token, so a typo would otherwise surface as a confusing auth failure at the destination. `updateNetwork` / `update_network` accepts the same callbacks, but its payload carries no `iam` config, so token names can't be validated client-side there and any name resolves to its placeholder. Static `transform: { headers }` objects keep working unchanged (including hand-written `${e2b.identity.tokens.<name>}` strings, which stay the escape hatch for tokens the SDK doesn't know about). Only `{ iam }` is exposed on the context for now — `${e2b.sandboxId}` / `${e2b.teamId}` / `${e2b.executionId}` from the older prototype are not part of the current backend design, so `sandbox` can be added later when there is something to resolve. ## Usage ```ts import { Sandbox, Secret } from 'e2b' const sandbox = await Sandbox.create({ iam: { tokens: { aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }), }, }, network: { // Only allow egress to hosts that have rules registered. allowOut: ({ rules }) => [...rules.keys()], rules: { 'api.internal.example.com': [ { transform: ({ iam }) => ({ headers: { Authorization: `Bearer ${iam.tokens.aws}` }, }), }, ], }, }, }) ``` ```python from e2b import Sandbox, Secret sandbox = Sandbox.create( iam={ "tokens": { "aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"), }, }, network={ "allow_out": lambda ctx: list(ctx.rules.keys()), "rules": { "api.internal.example.com": [ { "transform": lambda ctx: { "headers": {"Authorization": f"Bearer {ctx.iam.tokens['aws']}"}, }, }, ], }, }, ) ``` Both send: ```json { "iam": { "tokens": { "aws": { "audience": "sts.amazonaws.com", "tokenType": "JWT-SVID" } } }, "network": { "allowOut": ["api.internal.example.com"], "rules": { "api.internal.example.com": [ { "transform": { "headers": { "Authorization": "Bearer ${e2b.identity.tokens.aws}" } } } ] } } } ``` ## Notes - `allowOut` / `deny_out` selectors run **before** transforms are resolved, so `ctx.rules` still hands back the rules you passed — a rule's `transform` there is the union (object or callback), not the materialized object. The `getInfo` view keeps its own narrowed `SandboxNetworkRuleInfo` type. - Token names are validated where they are registered and again before interpolation: a name cannot be empty or contain `{`, `}` or control characters. The proxy reads a placeholder up to its first `}`, so `a}b` would mint the unrelated token `a` and leave `b}` as literal text, and a `{` in a name can open a second placeholder. The interpolation check is what covers `updateNetwork`, where any name the callback looks up becomes a placeholder without passing through the `iam` config. - Every lookup form on `iam.tokens` is guarded, not just `[name]`: Python's map is a `Mapping` whose `__getitem__` owns resolution (so `.get('typo')` raises instead of returning `None`), and membership (`'aws' in ctx.iam.tokens` / `'aws' in iam.tokens`) answers "is it registered?" without raising so a callback can branch on it. Lookup checks own keys only, so an unregistered name colliding with an object member (`constructor`, `__proto__`) reports as unregistered instead of resolving to a built-in; the four properties the runtime itself reads (`toJSON`, `then`, `toString`, `valueOf`) still resolve normally, so serializing, awaiting or coercing the map does not trip the guard. - A callback must be synchronous and return a plain transform object; a promise (from an `async` callback), an array, a `Map`/`Date`/class instance, or a missing return value is rejected with an actionable error rather than silently creating a rule with no headers. The awaitable is closed/caught so you don't also get an unawaited-coroutine warning or an unhandled rejection. ## Tests New payload-level tests: JS `tests/sandbox/networkTransform.test.ts` (msw), Python `tests/shared/sandbox/test_network_transform.py` — shared rather than mirrored into the sync and async suites, since they only exercise the shared builders. They cover placeholder resolution, enumerating and membership-testing registered tokens, `JSON.stringify` of the context not tripping the guard, static transforms staying byte-identical, `transform: null`, the unregistered-name rejection through both `[name]` and `.get()`, the no-`iam` rejection, non-transform and `async` return values, unusable token names (both braces, a smuggled placeholder, a newline, empty) at registration and on the update path, and the permissive `updateNetwork` path. Verified against production on all three surfaces (JS, sync Python, async Python), where: 1. a static transform carrying `Bearer ${e2b.identity.tokens.aws}` is accepted by `validateNetworkRules` and round-trips through `getInfo` / `get_info` unchanged; 2. the callback-resolved payload reaches the API and is answered with the expected team-gating error (`400: Sandbox IAM workload tokens are not available for your team.`), since `iam` is still feature-flagged; 3. a misspelled or unusable token name is rejected client-side before any request is made. Proxy-side substitution of the placeholder ships separately in belt (EN-1864); until then the header value is forwarded verbatim, which is why there is no end-to-end injection test here. Part of SDK-245. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
---
|
||||
'e2b': minor
|
||||
'@e2b/python-sdk': minor
|
||||
---
|
||||
|
||||
Allow a network rule's `transform` to be a callback, so a workload identity token from the `iam` option can be injected into egress requests without the SDK ever seeing its value. The callback receives placeholder strings that the egress proxy resolves per request — `iam.tokens.aws` is `${e2b.identity.tokens.aws}` on the wire — and referencing a token that is not registered in `iam.tokens` fails with `InvalidArgumentError` / `InvalidArgumentException` instead of silently sending a placeholder no token will ever replace.
|
||||
|
||||
`updateNetwork` / `update_network` accepts the same callbacks, but its payload carries no `iam` config, so token names cannot be checked there and every name resolves to its placeholder.
|
||||
|
||||
Token names are validated where they are registered and again before they are interpolated: a name cannot be empty or contain `{`, `}` or control characters, since the proxy reads a placeholder up to its first `}` and a brace in the name would resolve a different token than the one referenced.
|
||||
|
||||
```ts
|
||||
import { Sandbox, Secret } from 'e2b'
|
||||
|
||||
const sandbox = await Sandbox.create({
|
||||
iam: {
|
||||
tokens: {
|
||||
aws: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
|
||||
},
|
||||
},
|
||||
network: {
|
||||
allowOut: ({ rules }) => [...rules.keys()],
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens.aws}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```python
|
||||
from e2b import Sandbox, Secret
|
||||
|
||||
sandbox = Sandbox.create(
|
||||
iam={
|
||||
"tokens": {
|
||||
"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID"),
|
||||
},
|
||||
},
|
||||
network={
|
||||
"allow_out": lambda ctx: list(ctx.rules.keys()),
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {"Authorization": f"Bearer {ctx.iam.tokens['aws']}"},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
```
|
||||
@@ -73,6 +73,8 @@ export type {
|
||||
SandboxNetworkRuleInfo,
|
||||
SandboxNetworkRules,
|
||||
SandboxNetworkTransform,
|
||||
SandboxNetworkTransformContext,
|
||||
SandboxNetworkTransformResolver,
|
||||
SandboxNetworkUpdate,
|
||||
SandboxOnTimeout,
|
||||
SandboxLifecycle,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { InvalidArgumentError } from '../errors'
|
||||
|
||||
/**
|
||||
* Characters a workload token name cannot carry.
|
||||
*
|
||||
* The egress proxy reads a placeholder as everything between
|
||||
* `'${e2b.identity.tokens.'` and the next `}`, then looks that name up in the
|
||||
* registered tokens. A brace in the name breaks that in both directions: `}`
|
||||
* ends the placeholder early, so `'a}b'` resolves the unrelated token `'a'` and
|
||||
* leaves `'b}'` as literal text, and `{` lets a name close its own placeholder
|
||||
* and open another one, minting a token the caller never referenced. Control
|
||||
* characters are rejected separately because they cannot appear in an HTTP
|
||||
* header value at all — the API would answer with an opaque 400.
|
||||
*/
|
||||
const INVALID_IAM_TOKEN_NAME_CHARS = /[{}\p{Cc}]/u
|
||||
|
||||
/**
|
||||
* Properties the language and the runtime read off any object they serialize,
|
||||
* await, or coerce to a string. A token is never named after them, so they
|
||||
* resolve normally instead of counting as a token reference — otherwise
|
||||
* `JSON.stringify(iam.tokens)` inside a callback would throw.
|
||||
*/
|
||||
const RUNTIME_PROBED_PROPS = new Set(['toJSON', 'then', 'toString', 'valueOf'])
|
||||
|
||||
/**
|
||||
* Reject a token name that cannot survive the placeholder grammar.
|
||||
*
|
||||
* @param name workload token name.
|
||||
*
|
||||
* @throws {@link InvalidArgumentError} if the name is empty or carries a brace
|
||||
* or control character.
|
||||
*/
|
||||
export function validateIamTokenName(name: string): void {
|
||||
if (name.length === 0 || INVALID_IAM_TOKEN_NAME_CHARS.test(name)) {
|
||||
throw new InvalidArgumentError(
|
||||
`iam token name ${JSON.stringify(name)} is not usable: a token name cannot be empty or contain '{', '}' or control characters, because it is interpolated into the '\${e2b.identity.tokens.<name>}' placeholder the egress proxy resolves.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder the egress proxy replaces with a freshly minted token. The
|
||||
* spelling is fixed by the backend: a placeholder can only select a persisted
|
||||
* named token, never an inline audience or claim.
|
||||
*/
|
||||
export function iamTokenPlaceholder(name: string): string {
|
||||
validateIamTokenName(name)
|
||||
|
||||
return `\${e2b.identity.tokens.${name}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Token name to placeholder map, as exposed to a network `transform` callback.
|
||||
*
|
||||
* `tokenNames` are the workload tokens the request registers. Referencing any
|
||||
* other name throws: the proxy never turns an unregistered name into a token, so
|
||||
* a typo would surface as a confusing auth failure at the destination instead of
|
||||
* an error here.
|
||||
*
|
||||
* `validate: false` is for the update-network endpoint, whose payload carries no
|
||||
* `iam` config — the sandbox's registered token names are not known client-side
|
||||
* there, so any name resolves to its placeholder.
|
||||
*/
|
||||
export function iamTokenPlaceholders(
|
||||
tokenNames: string[],
|
||||
{ validate }: { validate: boolean }
|
||||
): Record<string, string> {
|
||||
const tokens: Record<string, string> = {}
|
||||
for (const name of tokenNames) {
|
||||
tokens[name] = iamTokenPlaceholder(name)
|
||||
}
|
||||
|
||||
return new Proxy(tokens, {
|
||||
get(target, prop, receiver) {
|
||||
if (
|
||||
typeof prop === 'string' &&
|
||||
// Own keys only: a bare `in` also matches inherited `Object.prototype`
|
||||
// members, so an unregistered token named `constructor` or `__proto__`
|
||||
// would resolve to a built-in instead of being reported. Python's
|
||||
// mapping treats them as missing too.
|
||||
!Object.hasOwn(target, prop) &&
|
||||
!RUNTIME_PROBED_PROPS.has(prop)
|
||||
) {
|
||||
if (!validate) {
|
||||
return iamTokenPlaceholder(prop)
|
||||
}
|
||||
|
||||
const hint =
|
||||
tokenNames.length === 0
|
||||
? `Pass it to Sandbox.create as iam: { tokens: { '${prop}': Secret.iamToken({ audience, tokenType }) } }.`
|
||||
: `Registered tokens: ${tokenNames.map((name) => `'${name}'`).join(', ')}.`
|
||||
|
||||
throw new InvalidArgumentError(
|
||||
`Network transform references iam token '${prop}', which is not registered. ${hint}`
|
||||
)
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
|
||||
// `name in iam.tokens` answers "is this token registered?", so it must
|
||||
// agree with the `get` trap above and not report inherited
|
||||
// `Object.prototype` members as tokens. Mirrors Python's
|
||||
// `IamTokenPlaceholders.__contains__`.
|
||||
has(target, prop) {
|
||||
return Object.hasOwn(target, prop)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../connectionConfig'
|
||||
import { compareVersions } from 'compare-versions'
|
||||
import { ALL_TRAFFIC } from './network'
|
||||
import { iamTokenPlaceholders, validateIamTokenName } from './iam'
|
||||
import {
|
||||
InvalidArgumentError,
|
||||
NotFoundError,
|
||||
@@ -52,14 +53,58 @@ export type SandboxNetworkTransform = {
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to a {@link SandboxNetworkRule} `transform` callback. Its
|
||||
* values are literal placeholder strings that the egress proxy resolves per
|
||||
* request, so the secret itself never leaves the platform.
|
||||
*/
|
||||
export type SandboxNetworkTransformContext = {
|
||||
/** Workload identity placeholders. */
|
||||
iam: {
|
||||
/**
|
||||
* Placeholder for each workload token registered in
|
||||
* {@link SandboxOpts.iam}, keyed by token name. `tokens.aws` is the string
|
||||
* `'${e2b.identity.tokens.aws}'`, which the egress proxy replaces with a
|
||||
* freshly minted token when it forwards the request.
|
||||
*
|
||||
* Reading a name that is not registered throws
|
||||
* {@link InvalidArgumentError} — the proxy never turns an unregistered name
|
||||
* into a token, so a typo would surface as a confusing auth failure at the
|
||||
* destination.
|
||||
*/
|
||||
tokens: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback form of {@link SandboxNetworkRule.transform}. Invoked once while the
|
||||
* request is being built, with a context of placeholder strings.
|
||||
*/
|
||||
export type SandboxNetworkTransformResolver = (
|
||||
ctx: SandboxNetworkTransformContext
|
||||
) => SandboxNetworkTransform
|
||||
|
||||
/**
|
||||
* Per-domain rule applied to egress requests.
|
||||
*/
|
||||
export type SandboxNetworkRule = {
|
||||
/**
|
||||
* Transform applied to requests matching this rule.
|
||||
*
|
||||
* Accepts either a static object or a callback that receives a
|
||||
* {@link SandboxNetworkTransformContext} of placeholder strings — use the
|
||||
* callback to inject a workload identity token the proxy mints per request.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* transform: ({ iam }) => ({
|
||||
* headers: { Authorization: `Bearer ${iam.tokens.aws}` },
|
||||
* }),
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
transform?: SandboxNetworkTransform
|
||||
transform?: SandboxNetworkTransform | SandboxNetworkTransformResolver
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +183,11 @@ export type SandboxNetworkOpts = {
|
||||
* also appear in {@link allowOut}. Hosts registered here are exposed to the
|
||||
* `allowOut`/`denyOut` callbacks via `rules`.
|
||||
*
|
||||
* A rule's `transform` can also be a callback receiving a
|
||||
* {@link SandboxNetworkTransformContext}, which is how a workload identity
|
||||
* token from {@link SandboxOpts.iam} gets injected without the SDK ever
|
||||
* seeing its value.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await Sandbox.create({
|
||||
@@ -147,6 +197,13 @@ export type SandboxNetworkOpts = {
|
||||
* 'api.openai.com': [
|
||||
* { transform: { headers: { Authorization: `Bearer ${token}` } } },
|
||||
* ],
|
||||
* 'api.internal.example.com': [
|
||||
* {
|
||||
* transform: ({ iam }) => ({
|
||||
* headers: { Authorization: `Bearer ${iam.tokens.aws}` },
|
||||
* }),
|
||||
* },
|
||||
* ],
|
||||
* },
|
||||
* },
|
||||
* })
|
||||
@@ -191,7 +248,12 @@ export type SandboxNetworkUpdate = {
|
||||
allowOut?: SandboxNetworkSelector
|
||||
/** See {@link SandboxNetworkOpts.denyOut}. */
|
||||
denyOut?: SandboxNetworkSelector
|
||||
/** See {@link SandboxNetworkOpts.rules}. */
|
||||
/**
|
||||
* See {@link SandboxNetworkOpts.rules}. A `transform` callback works here
|
||||
* too, but the update payload carries no `iam` config, so token names cannot
|
||||
* be checked against the sandbox's registered tokens — every name resolves to
|
||||
* its placeholder and a typo only surfaces at the destination.
|
||||
*/
|
||||
rules?: SandboxNetworkRules
|
||||
/**
|
||||
* Allow sandbox to access the internet. When set to `false`, it behaves the
|
||||
@@ -230,6 +292,10 @@ export interface SandboxIamOpts {
|
||||
/**
|
||||
* Named workload-token definitions, keyed by a caller-chosen token name.
|
||||
* Values can be created with `Secret.iamToken()`.
|
||||
*
|
||||
* A name is interpolated into the `'${e2b.identity.tokens.<name>}'`
|
||||
* placeholder a network transform resolves, so it cannot be empty or contain
|
||||
* `{`, `}` or control characters.
|
||||
*/
|
||||
tokens?: Record<string, SandboxIamToken>
|
||||
}
|
||||
@@ -443,6 +509,10 @@ export interface SandboxOpts extends ConnectionOpts {
|
||||
* Sandbox workload identity configuration. Providing a non-empty
|
||||
* `tokens` map enables workload identity for the sandbox.
|
||||
*
|
||||
* Registered tokens are exposed to {@link SandboxNetworkOpts.rules}
|
||||
* `transform` callbacks as `iam.tokens.<name>` placeholders, which the egress
|
||||
* proxy resolves per request.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const sandbox = await Sandbox.create({
|
||||
@@ -735,14 +805,79 @@ function resolveNetworkSelector(
|
||||
return selector
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the context handed to `transform` callbacks. See
|
||||
* {@link iamTokenPlaceholders} for what `validate` controls.
|
||||
*/
|
||||
function buildTransformContext(
|
||||
tokenNames: string[],
|
||||
{ validate }: { validate: boolean }
|
||||
): SandboxNetworkTransformContext {
|
||||
return { iam: { tokens: iamTokenPlaceholders(tokenNames, { validate }) } }
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/** Name the shape a `transform` callback returned, for the error message. */
|
||||
function describeValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
return 'null'
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return typeof value
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? 'array' : (value.constructor?.name ?? 'object')
|
||||
}
|
||||
|
||||
function resolveRulesForBody(
|
||||
rules: Map<string, SandboxNetworkRule[]>
|
||||
rules: Map<string, SandboxNetworkRule[]>,
|
||||
ctx: SandboxNetworkTransformContext
|
||||
): Record<string, { transform?: SandboxNetworkTransform }[]> {
|
||||
const out: Record<string, { transform?: SandboxNetworkTransform }[]> = {}
|
||||
for (const [host, hostRules] of rules) {
|
||||
out[host] = hostRules.map((rule) =>
|
||||
rule.transform === undefined ? {} : { transform: rule.transform }
|
||||
)
|
||||
out[host] = hostRules.map((rule) => {
|
||||
// `== null` also covers an explicit `transform: null`, which Python's
|
||||
// `rule.get('transform') is None` treats as no transform too.
|
||||
if (rule.transform == null) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof rule.transform !== 'function') {
|
||||
return { transform: rule.transform }
|
||||
}
|
||||
|
||||
const transform: unknown = rule.transform(ctx)
|
||||
// A callback that returns something other than a transform resolves to no
|
||||
// headers at all, which would silently create the rule without the headers
|
||||
// it is for.
|
||||
if (typeof (transform as PromiseLike<unknown>)?.then === 'function') {
|
||||
// Swallow a later rejection so the caller gets this error instead of an
|
||||
// unhandled rejection.
|
||||
void Promise.resolve(transform).catch(() => {})
|
||||
throw new InvalidArgumentError(
|
||||
`Network transform callback for '${host}' must be synchronous, it returned a promise. Resolve the value before creating the sandbox.`
|
||||
)
|
||||
}
|
||||
|
||||
// Mirrors Python's `isinstance(transform, Mapping)`: an array, `Map`,
|
||||
// `Date` or class instance serializes to something the API cannot read.
|
||||
if (!isPlainObject(transform)) {
|
||||
throw new InvalidArgumentError(
|
||||
`Network transform callback for '${host}' must return a transform object, got ${describeValue(transform)}.`
|
||||
)
|
||||
}
|
||||
|
||||
return { transform: transform as SandboxNetworkTransform }
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -753,11 +888,14 @@ type NetworkEgressBody = {
|
||||
rules?: Record<string, { transform?: SandboxNetworkTransform }[]>
|
||||
}
|
||||
|
||||
function buildNetworkEgress(network: {
|
||||
allowOut?: SandboxNetworkSelector
|
||||
denyOut?: SandboxNetworkSelector
|
||||
rules?: SandboxNetworkRules
|
||||
}): NetworkEgressBody {
|
||||
function buildNetworkEgress(
|
||||
network: {
|
||||
allowOut?: SandboxNetworkSelector
|
||||
denyOut?: SandboxNetworkSelector
|
||||
rules?: SandboxNetworkRules
|
||||
},
|
||||
transformContext: SandboxNetworkTransformContext
|
||||
): NetworkEgressBody {
|
||||
const rules =
|
||||
network.rules instanceof Map
|
||||
? network.rules
|
||||
@@ -769,20 +907,24 @@ function buildNetworkEgress(network: {
|
||||
...(allowOut !== undefined ? { allowOut } : {}),
|
||||
...(denyOut !== undefined ? { denyOut } : {}),
|
||||
...(network.rules !== undefined
|
||||
? { rules: resolveRulesForBody(rules) }
|
||||
? { rules: resolveRulesForBody(rules, transformContext) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function buildNetworkBody(
|
||||
network: SandboxNetworkOpts | undefined
|
||||
network: SandboxNetworkOpts | undefined,
|
||||
iam: components['schemas']['SandboxIam'] | undefined
|
||||
): components['schemas']['SandboxNetworkConfig'] | undefined {
|
||||
if (!network) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...buildNetworkEgress(network),
|
||||
...buildNetworkEgress(
|
||||
network,
|
||||
buildTransformContext(Object.keys(iam?.tokens ?? {}), { validate: true })
|
||||
),
|
||||
...(network.allowPublicTraffic !== undefined
|
||||
? { allowPublicTraffic: network.allowPublicTraffic }
|
||||
: {}),
|
||||
@@ -815,6 +957,11 @@ function buildIamBody(
|
||||
)
|
||||
}
|
||||
|
||||
// A network transform placeholder is the only way to consume a workload
|
||||
// token, so a name that cannot appear in one is rejected where it is
|
||||
// registered rather than at the reference that would have used it.
|
||||
validateIamTokenName(name)
|
||||
|
||||
tokens[name] = { audience: token.audience, tokenType: token.tokenType }
|
||||
}
|
||||
|
||||
@@ -831,7 +978,10 @@ function buildNetworkUpdateBody(
|
||||
network: SandboxNetworkUpdate
|
||||
): components['schemas']['SandboxNetworkUpdateConfig'] {
|
||||
return {
|
||||
...buildNetworkEgress(network),
|
||||
...buildNetworkEgress(
|
||||
network,
|
||||
buildTransformContext([], { validate: false })
|
||||
),
|
||||
...(network.allowInternetAccess !== undefined
|
||||
? { allow_internet_access: network.allowInternetAccess }
|
||||
: {}),
|
||||
@@ -1291,6 +1441,10 @@ export class SandboxApi {
|
||||
)
|
||||
}
|
||||
|
||||
// Built before the network config: `transform` callbacks are resolved
|
||||
// against the workload tokens this request registers.
|
||||
const iam = buildIamBody(opts?.iam)
|
||||
|
||||
const body: components['schemas']['NewSandbox'] = {
|
||||
templateID: template,
|
||||
metadata: opts?.metadata,
|
||||
@@ -1299,8 +1453,8 @@ export class SandboxApi {
|
||||
timeout: timeoutToSeconds(timeoutMs),
|
||||
secure: opts?.secure ?? true,
|
||||
allow_internet_access: opts?.allowInternetAccess ?? true,
|
||||
network: buildNetworkBody(opts?.network),
|
||||
iam: buildIamBody(opts?.iam),
|
||||
network: buildNetworkBody(opts?.network, iam),
|
||||
iam,
|
||||
autoPause: action === 'pause',
|
||||
autoPauseMemory: action === 'pause' ? keepMemory : undefined,
|
||||
autoResume: { enabled: autoResume },
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
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'
|
||||
|
||||
const sandboxId = 'test-sandbox-id'
|
||||
|
||||
let lastCreateBody: Record<string, any> | undefined
|
||||
let lastUpdateBody: Record<string, any> | undefined
|
||||
|
||||
const server = setupServer(
|
||||
http.post(apiUrl('/sandboxes'), async ({ request }) => {
|
||||
lastCreateBody = (await request.json()) as Record<string, any>
|
||||
return HttpResponse.json({
|
||||
sandboxID: sandboxId,
|
||||
templateID: 'base',
|
||||
envdVersion: '0.2.4',
|
||||
})
|
||||
}),
|
||||
http.put(apiUrl(`/sandboxes/${sandboxId}/network`), async ({ request }) => {
|
||||
lastUpdateBody = (await request.json()) as Record<string, any>
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
})
|
||||
)
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
|
||||
afterAll(() => server.close())
|
||||
|
||||
afterEach(() => {
|
||||
lastCreateBody = undefined
|
||||
lastUpdateBody = undefined
|
||||
server.resetHandlers()
|
||||
})
|
||||
|
||||
const awsToken = Secret.iamToken({
|
||||
audience: 'sts.amazonaws.com',
|
||||
tokenType: 'JWT-SVID',
|
||||
})
|
||||
|
||||
test('transform callback resolves an iam token to its placeholder', async () => {
|
||||
await Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
iam: { tokens: { aws: awsToken } },
|
||||
network: {
|
||||
allowOut: ({ rules }) => [...rules.keys()],
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens.aws}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(lastCreateBody?.network).toEqual({
|
||||
allowOut: ['api.internal.example.com'],
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: {
|
||||
headers: {
|
||||
// The SDK never resolves the placeholder — the egress proxy
|
||||
// substitutes a freshly minted token per request.
|
||||
Authorization: 'Bearer ${e2b.identity.tokens.aws}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('transform callback sees every registered iam token', async () => {
|
||||
await Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
iam: { tokens: { aws: awsToken, gcp: awsToken } },
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: {
|
||||
'X-Tokens': Object.keys(iam.tokens).join(','),
|
||||
// Membership answers "is it registered?" without throwing, so a
|
||||
// callback can branch on it.
|
||||
'X-Has-Aws': String('aws' in iam.tokens),
|
||||
'X-Has-Gh': String('gh' in iam.tokens),
|
||||
// Membership must agree with a lookup: an inherited object
|
||||
// member is not a registered token either.
|
||||
'X-Has-Ctor': String('constructor' in iam.tokens),
|
||||
// Serializing the context must not trip the unknown-token guard
|
||||
// on the runtime's `toJSON` probe.
|
||||
'X-Json': JSON.stringify(iam.tokens),
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
lastCreateBody?.network.rules['api.internal.example.com'][0].transform
|
||||
.headers
|
||||
).toEqual({
|
||||
'X-Tokens': 'aws,gcp',
|
||||
'X-Has-Aws': 'true',
|
||||
'X-Has-Gh': 'false',
|
||||
'X-Has-Ctor': 'false',
|
||||
'X-Json': JSON.stringify({
|
||||
aws: '${e2b.identity.tokens.aws}',
|
||||
gcp: '${e2b.identity.tokens.gcp}',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('a static transform is still sent unchanged', async () => {
|
||||
await Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
network: {
|
||||
rules: {
|
||||
'api.openai.com': [
|
||||
{ transform: { headers: { Authorization: 'Bearer static' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(lastCreateBody?.network.rules).toEqual({
|
||||
'api.openai.com': [
|
||||
{ transform: { headers: { Authorization: 'Bearer static' } } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('transform callback rejects an unregistered iam token', async () => {
|
||||
await expect(
|
||||
Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
iam: { tokens: { aws: awsToken } },
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens.awz}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(/iam token 'awz'.*Registered tokens: 'aws'/s)
|
||||
|
||||
expect(lastCreateBody).toBeUndefined()
|
||||
})
|
||||
|
||||
test.for(['constructor', '__proto__', 'hasOwnProperty'])(
|
||||
'transform callback rejects an unregistered iam token named %s',
|
||||
async (name: string) => {
|
||||
// Inherited Object.prototype members are not registered tokens; resolving
|
||||
// them would put a built-in function in the header. Python's mapping treats
|
||||
// them as missing too.
|
||||
await expect(
|
||||
Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
iam: { tokens: { aws: awsToken } },
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens[name]}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(`iam token '${name}', which is not registered`)
|
||||
|
||||
expect(lastCreateBody).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
test('transform callback rejects an iam token when no iam config is set', async () => {
|
||||
await expect(
|
||||
Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens.aws}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(InvalidArgumentError)
|
||||
|
||||
expect(lastCreateBody).toBeUndefined()
|
||||
})
|
||||
|
||||
test.for([
|
||||
['undefined', () => undefined],
|
||||
['string', () => 'headers'],
|
||||
['array', () => [{ headers: {} }]],
|
||||
['Map', () => new Map([['headers', {}]])],
|
||||
])(
|
||||
'transform callback returning a %s is rejected',
|
||||
async ([, transform]: [string, () => unknown]) => {
|
||||
// Untyped callers can forget the return value or return the wrong shape; the
|
||||
// rule would otherwise be created without the headers it exists for.
|
||||
await expect(
|
||||
Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [{ transform: transform as never }],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
/must return a transform object, got (undefined|string|array|Map)/
|
||||
)
|
||||
|
||||
expect(lastCreateBody).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
test('async transform callback is rejected', async () => {
|
||||
await expect(
|
||||
Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: (async () => ({
|
||||
headers: { Authorization: 'Bearer late' },
|
||||
})) as never,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(/must be synchronous/)
|
||||
|
||||
expect(lastCreateBody).toBeUndefined()
|
||||
})
|
||||
|
||||
test('an explicit null transform sends an empty rule', async () => {
|
||||
// Rules built from parsed JSON spell "no transform" as null; Python treats it
|
||||
// the same way.
|
||||
await Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
network: {
|
||||
rules: { 'api.internal.example.com': [{ transform: null as never }] },
|
||||
},
|
||||
})
|
||||
|
||||
expect(lastCreateBody?.network.rules).toEqual({
|
||||
'api.internal.example.com': [{}],
|
||||
})
|
||||
})
|
||||
|
||||
test.for([
|
||||
['a}b', 'closing brace'],
|
||||
['a{b', 'opening brace'],
|
||||
['aws}${e2b.identity.tokens.gcp', 'smuggled placeholder'],
|
||||
['a\nb', 'control character'],
|
||||
['', 'empty'],
|
||||
])(
|
||||
'an iam token name with a %s is rejected',
|
||||
async ([name]: [string, string]) => {
|
||||
// The proxy reads a placeholder up to its first `}`, so a brace in the name
|
||||
// resolves a different token than the one referenced — 'a}b' would mint 'a'
|
||||
// and leave 'b}' as literal text.
|
||||
await expect(
|
||||
Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
iam: { tokens: { [name]: awsToken } },
|
||||
})
|
||||
).rejects.toThrowError(/is not usable/)
|
||||
|
||||
expect(lastCreateBody).toBeUndefined()
|
||||
|
||||
// The update path takes any name from the callback, so it has to check again
|
||||
// at the point of interpolation.
|
||||
await expect(
|
||||
Sandbox.updateNetwork(
|
||||
sandboxId,
|
||||
{
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens[name]}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ apiKey: TEST_API_KEY }
|
||||
)
|
||||
).rejects.toThrowError(/is not usable/)
|
||||
|
||||
expect(lastUpdateBody).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
test('an ordinary iam token name is accepted', async () => {
|
||||
await Sandbox.create('base', {
|
||||
apiKey: TEST_API_KEY,
|
||||
iam: { tokens: { 'aws.prod-1_x': awsToken } },
|
||||
network: {
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: {
|
||||
Authorization: `Bearer ${iam.tokens['aws.prod-1_x']}`,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
lastCreateBody?.network.rules['api.internal.example.com'][0].transform
|
||||
.headers
|
||||
).toEqual({
|
||||
Authorization: 'Bearer ${e2b.identity.tokens.aws.prod-1_x}',
|
||||
})
|
||||
})
|
||||
|
||||
test('updateNetwork resolves transform callbacks without an iam config', async () => {
|
||||
// The update payload carries no iam config, so the sandbox's registered token
|
||||
// names are unknown client-side and any name resolves to its placeholder.
|
||||
await Sandbox.updateNetwork(
|
||||
sandboxId,
|
||||
{
|
||||
allowOut: ({ rules }) => [...rules.keys()],
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: ({ iam }) => ({
|
||||
headers: { Authorization: `Bearer ${iam.tokens.aws}` },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ apiKey: TEST_API_KEY }
|
||||
)
|
||||
|
||||
expect(lastUpdateBody).toEqual({
|
||||
allowOut: ['api.internal.example.com'],
|
||||
rules: {
|
||||
'api.internal.example.com': [
|
||||
{
|
||||
transform: {
|
||||
headers: { Authorization: 'Bearer ${e2b.identity.tokens.aws}' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -91,6 +91,8 @@ from .sandbox.sandbox_api import (
|
||||
SandboxNetworkSelector,
|
||||
SandboxNetworkSelectorContext,
|
||||
SandboxNetworkTransform,
|
||||
SandboxNetworkTransformContext,
|
||||
SandboxNetworkTransformResolver,
|
||||
SandboxNetworkUpdate,
|
||||
SandboxQuery,
|
||||
SandboxState,
|
||||
@@ -204,6 +206,8 @@ __all__ = [
|
||||
"SandboxNetworkRuleInfo",
|
||||
"SandboxNetworkRules",
|
||||
"SandboxNetworkTransform",
|
||||
"SandboxNetworkTransformContext",
|
||||
"SandboxNetworkTransformResolver",
|
||||
"SandboxNetworkUpdate",
|
||||
"SandboxLifecycle",
|
||||
"SandboxOnTimeout",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Workload identity (iam) helpers for E2B sandboxes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Iterable, Iterator, Mapping
|
||||
|
||||
from e2b.exceptions import InvalidArgumentException
|
||||
|
||||
|
||||
# Characters a workload token name cannot carry.
|
||||
#
|
||||
# The egress proxy reads a placeholder as everything between
|
||||
# "${e2b.identity.tokens." and the next "}", then looks that name up in the
|
||||
# registered tokens. A brace in the name breaks that in both directions: "}"
|
||||
# ends the placeholder early, so "a}b" resolves the unrelated token "a" and
|
||||
# leaves "b}" as literal text, and "{" lets a name close its own placeholder and
|
||||
# open another one, minting a token the caller never referenced. Control
|
||||
# characters are rejected separately because they cannot appear in an HTTP
|
||||
# header value at all — the API would answer with an opaque 400. The class is
|
||||
# the Unicode Control category (U+0000-U+001F, U+007F-U+009F), spelled
|
||||
# `\p{Cc}` in the JS SDK.
|
||||
INVALID_IAM_TOKEN_NAME_CHARS = re.compile(r"[{}\x00-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
def validate_iam_token_name(name: str) -> None:
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or not name
|
||||
or INVALID_IAM_TOKEN_NAME_CHARS.search(name)
|
||||
):
|
||||
raise InvalidArgumentException(
|
||||
f"iam token name {name!r} is not usable: a token name must be a "
|
||||
"non-empty string and cannot contain '{', '}' or control "
|
||||
"characters, because it is interpolated into the "
|
||||
"'${e2b.identity.tokens.<name>}' placeholder the egress proxy "
|
||||
"resolves."
|
||||
)
|
||||
|
||||
|
||||
def iam_token_placeholder(name: str) -> str:
|
||||
validate_iam_token_name(name)
|
||||
|
||||
return f"${{e2b.identity.tokens.{name}}}"
|
||||
|
||||
|
||||
class IamTokenPlaceholders(Mapping[str, str]):
|
||||
"""
|
||||
Workload token placeholders keyed by token name, as exposed to a
|
||||
``transform`` callable. Every lookup — ``[name]``, ``get(name)`` — resolves
|
||||
through :meth:`__getitem__`, so an unregistered name cannot slip through as
|
||||
``None``; iteration and ``in`` see only the registered names.
|
||||
|
||||
``validate=False`` is for the update-network endpoint, whose payload carries
|
||||
no ``iam`` config — the sandbox's registered token names are not known
|
||||
client-side there, so any name resolves to its placeholder.
|
||||
"""
|
||||
|
||||
def __init__(self, names: Iterable[str], *, validate: bool) -> None:
|
||||
# dict.fromkeys keeps registration order while dropping duplicates.
|
||||
self._names = tuple(dict.fromkeys(names))
|
||||
self._validate = validate
|
||||
|
||||
def __getitem__(self, name: str) -> str:
|
||||
# The proxy never turns an unregistered name into a token, so a typo
|
||||
# would surface as a confusing auth failure at the destination instead
|
||||
# of an error here.
|
||||
if not self._validate or name in self._names:
|
||||
return iam_token_placeholder(name)
|
||||
|
||||
hint = (
|
||||
f"Registered tokens: {', '.join(repr(known) for known in self._names)}."
|
||||
if self._names
|
||||
else (
|
||||
f"Pass it to Sandbox.create as iam={{'tokens': "
|
||||
f"{{{name!r}: Secret.iam_token(audience=..., token_type=...)}}}}."
|
||||
)
|
||||
)
|
||||
raise InvalidArgumentException(
|
||||
f"Network transform references iam token {name!r}, which is not "
|
||||
f"registered. {hint}"
|
||||
)
|
||||
|
||||
def __contains__(self, name: object) -> bool:
|
||||
# Membership answers "is this registered?" and never raises, so callables
|
||||
# can branch on it; mirrors `name in iam.tokens` in the JS SDK.
|
||||
return name in self._names
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._names)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._names)
|
||||
@@ -1,9 +1,11 @@
|
||||
import inspect
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
@@ -57,6 +59,10 @@ from e2b.api.client.types import Unset
|
||||
from e2b.connection_config import ApiParams
|
||||
from e2b.exceptions import InvalidArgumentException
|
||||
from e2b.sandbox.mcp import McpServer as BaseMcpServer
|
||||
from e2b.sandbox.iam import (
|
||||
IamTokenPlaceholders,
|
||||
validate_iam_token_name,
|
||||
)
|
||||
from e2b.sandbox.network import ALL_TRAFFIC
|
||||
from e2b.paginator import PaginatorBase
|
||||
|
||||
@@ -101,14 +107,66 @@ class SandboxNetworkTransform(TypedDict):
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SandboxNetworkTransformIam:
|
||||
"""Workload identity placeholders."""
|
||||
|
||||
tokens: Mapping[str, str]
|
||||
"""
|
||||
Placeholder for each workload token registered in
|
||||
:attr:`SandboxOpts.iam`, keyed by token name. ``tokens["aws"]`` is the
|
||||
string ``"${e2b.identity.tokens.aws}"``, which the egress proxy replaces
|
||||
with a freshly minted token when it forwards the request.
|
||||
|
||||
Reading a name that is not registered raises
|
||||
:class:`InvalidArgumentException` — the proxy never turns an unregistered
|
||||
name into a token, so a typo would surface as a confusing auth failure at
|
||||
the destination.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxNetworkTransformContext:
|
||||
"""
|
||||
Context passed to a :class:`SandboxNetworkRule` ``transform`` callable. Its
|
||||
values are literal placeholder strings that the egress proxy resolves per
|
||||
request, so the secret itself never leaves the platform.
|
||||
"""
|
||||
|
||||
iam: _SandboxNetworkTransformIam
|
||||
"""Workload identity placeholders."""
|
||||
|
||||
|
||||
SandboxNetworkTransformResolver = Callable[
|
||||
[SandboxNetworkTransformContext], SandboxNetworkTransform
|
||||
]
|
||||
"""
|
||||
Callable form of ``SandboxNetworkRule.transform``. Invoked once while the
|
||||
request is being built, with a context of placeholder strings.
|
||||
"""
|
||||
|
||||
|
||||
class SandboxNetworkRule(TypedDict):
|
||||
"""
|
||||
Per-domain rule applied to egress requests.
|
||||
"""
|
||||
|
||||
transform: NotRequired[SandboxNetworkTransform]
|
||||
transform: NotRequired[
|
||||
Union[SandboxNetworkTransform, SandboxNetworkTransformResolver]
|
||||
]
|
||||
"""
|
||||
Transform applied to requests matching this rule.
|
||||
|
||||
Accepts either a static :class:`SandboxNetworkTransform` or a callable that
|
||||
receives a :class:`SandboxNetworkTransformContext` of placeholder strings —
|
||||
use the callable to inject a workload identity token the proxy mints per
|
||||
request::
|
||||
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {"Authorization": f"Bearer {ctx.iam.tokens['aws']}"},
|
||||
},
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -195,6 +253,23 @@ class SandboxNetworkOpts(TypedDict):
|
||||
Registering a host here does not allow egress on its own — the host must
|
||||
also appear in ``allow_out``. Hosts registered here are exposed to the
|
||||
``allow_out``/``deny_out`` callables via ``ctx.rules``.
|
||||
|
||||
A rule's ``transform`` can also be a callable receiving a
|
||||
:class:`SandboxNetworkTransformContext`, which is how a workload identity
|
||||
token from :attr:`SandboxOpts.iam` gets injected without the SDK ever
|
||||
seeing its value::
|
||||
|
||||
rules={
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {ctx.iam.tokens['aws']}",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
"""
|
||||
|
||||
allow_public_traffic: NotRequired[bool]
|
||||
@@ -227,7 +302,12 @@ class SandboxNetworkUpdate(TypedDict, total=False):
|
||||
"""See :attr:`SandboxNetworkOpts.deny_out`."""
|
||||
|
||||
rules: SandboxNetworkRules
|
||||
"""See :attr:`SandboxNetworkOpts.rules`."""
|
||||
"""
|
||||
See :attr:`SandboxNetworkOpts.rules`. A ``transform`` callable works here
|
||||
too, but the update payload carries no ``iam`` config, so token names cannot
|
||||
be checked against the sandbox's registered tokens — every name resolves to
|
||||
its placeholder and a typo only surfaces at the destination.
|
||||
"""
|
||||
|
||||
allow_internet_access: bool
|
||||
"""
|
||||
@@ -265,6 +345,10 @@ class SandboxIamOpts(TypedDict, total=False):
|
||||
"""
|
||||
Named workload-token definitions, keyed by a caller-chosen token name.
|
||||
Values can be created with :meth:`Secret.iam_token`.
|
||||
|
||||
A name is interpolated into the ``"${e2b.identity.tokens.<name>}"``
|
||||
placeholder a network transform resolves, so it cannot be empty or contain
|
||||
``{``, ``}`` or control characters.
|
||||
"""
|
||||
|
||||
|
||||
@@ -381,7 +465,28 @@ def _resolve_network_selector(
|
||||
return list(selector)
|
||||
|
||||
|
||||
def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules:
|
||||
def _build_transform_context(
|
||||
token_names: Iterable[str],
|
||||
*,
|
||||
validate: bool,
|
||||
) -> SandboxNetworkTransformContext:
|
||||
"""
|
||||
Build the context handed to ``transform`` callables.
|
||||
|
||||
``token_names`` are the workload tokens the request registers; see
|
||||
:class:`_IamTokenPlaceholders` for what ``validate`` controls.
|
||||
"""
|
||||
return SandboxNetworkTransformContext(
|
||||
iam=_SandboxNetworkTransformIam(
|
||||
tokens=IamTokenPlaceholders(token_names, validate=validate),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_client_rules(
|
||||
rules: SandboxNetworkRules,
|
||||
ctx: SandboxNetworkTransformContext,
|
||||
) -> SandboxNetworkConfigRules:
|
||||
client_rules = SandboxNetworkConfigRules()
|
||||
for host, host_rules in rules.items():
|
||||
converted: List[ClientSandboxNetworkRule] = []
|
||||
@@ -391,6 +496,27 @@ def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules
|
||||
converted.append(ClientSandboxNetworkRule())
|
||||
continue
|
||||
|
||||
if callable(transform):
|
||||
transform = transform(ctx)
|
||||
# A callable that returns something other than a transform
|
||||
# resolves to no headers at all, which would silently create the
|
||||
# rule without the headers it is for.
|
||||
if inspect.isawaitable(transform):
|
||||
if inspect.iscoroutine(transform):
|
||||
# Close it so the caller does not also get a
|
||||
# "coroutine was never awaited" RuntimeWarning.
|
||||
transform.close()
|
||||
raise InvalidArgumentException(
|
||||
f"Network transform callable for {host!r} must be "
|
||||
"synchronous, it returned an awaitable. Resolve the "
|
||||
"value before creating the sandbox."
|
||||
)
|
||||
if not isinstance(transform, Mapping):
|
||||
raise InvalidArgumentException(
|
||||
f"Network transform callable for {host!r} must return a "
|
||||
f"transform dict, got {type(transform).__name__}."
|
||||
)
|
||||
|
||||
client_transform = ClientSandboxNetworkTransform()
|
||||
headers = transform.get("headers")
|
||||
if headers:
|
||||
@@ -406,6 +532,7 @@ def _build_client_rules(rules: SandboxNetworkRules) -> SandboxNetworkConfigRules
|
||||
|
||||
def _build_network_egress(
|
||||
network: Mapping[str, Any],
|
||||
ctx: SandboxNetworkTransformContext,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve the shared egress fields (``allow_out`` / ``deny_out`` / per-host
|
||||
@@ -423,19 +550,31 @@ def _build_network_egress(
|
||||
if deny_out is not None:
|
||||
body["deny_out"] = deny_out
|
||||
if "rules" in network and network["rules"] is not None:
|
||||
body["rules"] = _build_client_rules(network["rules"]).additional_properties
|
||||
body["rules"] = _build_client_rules(network["rules"], ctx).additional_properties
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def build_network_config(
|
||||
network: Optional[SandboxNetworkOpts],
|
||||
iam: Optional[ClientSandboxIam] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a :class:`SandboxNetworkOpts` into the dict the API expects."""
|
||||
"""Resolve a :class:`SandboxNetworkOpts` into the dict the API expects.
|
||||
|
||||
``iam`` is the built workload identity config of the same request — its
|
||||
token names are what ``transform`` callables may reference.
|
||||
"""
|
||||
if network is None:
|
||||
return None
|
||||
|
||||
body = _build_network_egress(network)
|
||||
token_names: List[str] = []
|
||||
if iam is not None and not isinstance(iam.tokens, Unset):
|
||||
token_names = iam.tokens.additional_keys
|
||||
|
||||
body = _build_network_egress(
|
||||
network,
|
||||
_build_transform_context(token_names, validate=True),
|
||||
)
|
||||
if "rules" in body:
|
||||
client_rules = SandboxNetworkConfigRules()
|
||||
client_rules.additional_properties = body["rules"]
|
||||
@@ -479,6 +618,11 @@ def build_iam_config(
|
||||
"'token_type' values (snake_case 'token_type', not 'tokenType')."
|
||||
)
|
||||
|
||||
# A network transform placeholder is the only way to consume a workload
|
||||
# token, so a name that cannot appear in one is rejected where it is
|
||||
# registered rather than at the reference that would have used it.
|
||||
validate_iam_token_name(name)
|
||||
|
||||
client_tokens[name] = ClientSandboxIamToken(
|
||||
audience=token["audience"],
|
||||
token_type=token["token_type"],
|
||||
@@ -493,7 +637,9 @@ def build_network_update_body(
|
||||
network: SandboxNetworkUpdate,
|
||||
) -> SandboxNetworkUpdateConfig:
|
||||
"""Resolve a :class:`SandboxNetworkUpdate` into the API client body."""
|
||||
egress = _build_network_egress(network)
|
||||
egress = _build_network_egress(
|
||||
network, _build_transform_context([], validate=False)
|
||||
)
|
||||
|
||||
body = SandboxNetworkUpdateConfig()
|
||||
if "allow_out" in egress:
|
||||
|
||||
@@ -194,8 +194,8 @@ class AsyncSandbox(SandboxApi):
|
||||
:param secure: Envd is secured with access token and cannot be used without it, defaults to `True`.
|
||||
:param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
|
||||
:param mcp: MCP server to enable in the sandbox
|
||||
:param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``.
|
||||
: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")}}``
|
||||
:param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``).
|
||||
: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"`` (default) or ``"pause"``, or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` (default ``True``) set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); ``auto_resume``: ``False`` (default) 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 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.
|
||||
|
||||
@@ -257,7 +257,10 @@ class SandboxApi(SandboxBase):
|
||||
"must be resumed explicitly using Sandbox.connect()."
|
||||
)
|
||||
|
||||
network_body = build_network_config(network)
|
||||
# Built before the network config: ``transform`` callables are resolved
|
||||
# against the workload tokens this request registers.
|
||||
iam_body = build_iam_config(iam)
|
||||
network_body = build_network_config(network, iam_body)
|
||||
body = NewSandbox(
|
||||
template_id=template,
|
||||
auto_pause=on_timeout == "pause",
|
||||
@@ -270,7 +273,7 @@ class SandboxApi(SandboxBase):
|
||||
secure=secure,
|
||||
allow_internet_access=allow_internet_access,
|
||||
network=SandboxNetworkConfig(**network_body) if network_body else UNSET,
|
||||
iam=build_iam_config(iam) or UNSET,
|
||||
iam=iam_body or UNSET,
|
||||
volume_mounts=volume_mounts if volume_mounts else UNSET,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,8 +190,8 @@ class Sandbox(SandboxApi):
|
||||
:param secure: Envd is secured with access token and cannot be used without it, defaults to `True`.
|
||||
:param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
|
||||
:param mcp: MCP server to enable in the sandbox
|
||||
:param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``.
|
||||
: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")}}``
|
||||
:param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``).
|
||||
: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"`` (default) or ``"pause"``, or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` (default ``True``) set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); ``auto_resume``: ``False`` (default) 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 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.
|
||||
|
||||
@@ -256,7 +256,10 @@ class SandboxApi(SandboxBase):
|
||||
"must be resumed explicitly using Sandbox.connect()."
|
||||
)
|
||||
|
||||
network_body = build_network_config(network)
|
||||
# Built before the network config: ``transform`` callables are resolved
|
||||
# against the workload tokens this request registers.
|
||||
iam_body = build_iam_config(iam)
|
||||
network_body = build_network_config(network, iam_body)
|
||||
body = NewSandbox(
|
||||
template_id=template,
|
||||
auto_pause=on_timeout == "pause",
|
||||
@@ -269,7 +272,7 @@ class SandboxApi(SandboxBase):
|
||||
secure=secure,
|
||||
allow_internet_access=allow_internet_access,
|
||||
network=SandboxNetworkConfig(**network_body) if network_body else UNSET,
|
||||
iam=build_iam_config(iam) or UNSET,
|
||||
iam=iam_body or UNSET,
|
||||
volume_mounts=volume_mounts if volume_mounts else UNSET,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from e2b import SandboxNetworkOpts, Secret
|
||||
from e2b.exceptions import InvalidArgumentException
|
||||
from e2b.sandbox.sandbox_api import (
|
||||
build_iam_config,
|
||||
build_network_config,
|
||||
build_network_update_body,
|
||||
)
|
||||
|
||||
|
||||
def _aws_iam():
|
||||
return build_iam_config(
|
||||
{
|
||||
"tokens": {
|
||||
"aws": Secret.iam_token(
|
||||
audience="sts.amazonaws.com", token_type="JWT-SVID"
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_transform_callable_resolves_iam_token_placeholder():
|
||||
network: SandboxNetworkOpts = {
|
||||
"allow_out": lambda ctx: list(ctx.rules.keys()),
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {ctx.iam.tokens['aws']}",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
body = build_network_config(network, _aws_iam())
|
||||
assert body is not None
|
||||
assert body["allow_out"] == ["api.internal.example.com"]
|
||||
# The SDK never resolves the placeholder — the egress proxy substitutes a
|
||||
# freshly minted token per request.
|
||||
assert body["rules"].to_dict() == {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${e2b.identity.tokens.aws}",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_transform_callable_sees_every_registered_iam_token():
|
||||
token = Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")
|
||||
iam = build_iam_config({"tokens": {"aws": token, "gcp": token}})
|
||||
|
||||
network: SandboxNetworkOpts = {
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {
|
||||
"X-Tokens": ",".join(ctx.iam.tokens),
|
||||
# Membership answers "is it registered?" without
|
||||
# raising, so a callable can branch on it.
|
||||
"X-Has-Aws": str("aws" in ctx.iam.tokens),
|
||||
"X-Has-Gh": str("gh" in ctx.iam.tokens),
|
||||
# Membership must agree with a lookup: a name that
|
||||
# shadows a mapping member is not a token either.
|
||||
"X-Has-Keys": str("keys" in ctx.iam.tokens),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
body = build_network_config(network, iam)
|
||||
assert body is not None
|
||||
assert body["rules"].to_dict() == {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": {
|
||||
"headers": {
|
||||
"X-Tokens": "aws,gcp",
|
||||
"X-Has-Aws": "True",
|
||||
"X-Has-Gh": "False",
|
||||
"X-Has-Keys": "False",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_static_transform_is_sent_unchanged():
|
||||
network: SandboxNetworkOpts = {
|
||||
"rules": {
|
||||
"api.openai.com": [
|
||||
{"transform": {"headers": {"Authorization": "Bearer static"}}},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
body = build_network_config(network)
|
||||
assert body is not None
|
||||
assert body["rules"].to_dict() == {
|
||||
"api.openai.com": [
|
||||
{"transform": {"headers": {"Authorization": "Bearer static"}}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _typo_network(access) -> Any:
|
||||
return cast(
|
||||
Any,
|
||||
{
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {"Authorization": f"Bearer {access(ctx)}"},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access",
|
||||
[
|
||||
# Every lookup form must reject an unregistered name — `.get()` returning
|
||||
# None would ship the literal header "Bearer None".
|
||||
pytest.param(lambda ctx: ctx.iam.tokens["awz"], id="getitem"),
|
||||
pytest.param(lambda ctx: ctx.iam.tokens.get("awz"), id="get"),
|
||||
# A name that shadows an object member is not a registered token either;
|
||||
# the JS SDK checks own keys only for the same reason.
|
||||
pytest.param(lambda ctx: ctx.iam.tokens["keys"], id="dunder-lookalike"),
|
||||
],
|
||||
)
|
||||
def test_transform_callable_rejects_unregistered_iam_token(access):
|
||||
# The proxy never turns an unregistered name into a token, so a typo would
|
||||
# surface as a confusing auth failure at the destination.
|
||||
with pytest.raises(InvalidArgumentException, match="Registered tokens: 'aws'"):
|
||||
build_network_config(_typo_network(access), _aws_iam())
|
||||
|
||||
# Same rule without any iam config at all.
|
||||
with pytest.raises(InvalidArgumentException, match="not registered"):
|
||||
build_network_config(_typo_network(access))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"returned",
|
||||
[
|
||||
pytest.param(None, id="none"),
|
||||
pytest.param("headers", id="str"),
|
||||
pytest.param([{"headers": {}}], id="list"),
|
||||
],
|
||||
)
|
||||
def test_transform_callable_returning_a_non_transform_is_rejected(returned):
|
||||
# Untyped callers can forget the return value or return the wrong shape; the
|
||||
# rule would otherwise be created without the headers it exists for.
|
||||
network = cast(
|
||||
Any,
|
||||
{
|
||||
"rules": {
|
||||
"api.internal.example.com": [{"transform": lambda ctx: returned}]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidArgumentException, match="must return a transform dict"):
|
||||
build_network_config(network)
|
||||
|
||||
|
||||
def test_async_transform_callable_is_rejected():
|
||||
async def transform(ctx):
|
||||
return {"headers": {"Authorization": "Bearer late"}}
|
||||
|
||||
network = cast(
|
||||
Any,
|
||||
{"rules": {"api.internal.example.com": [{"transform": transform}]}},
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidArgumentException, match="must be synchronous"):
|
||||
build_network_config(network)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
pytest.param("a}b", id="closing-brace"),
|
||||
pytest.param("a{b", id="opening-brace"),
|
||||
pytest.param("aws}${e2b.identity.tokens.gcp", id="smuggled-placeholder"),
|
||||
pytest.param("a\nb", id="control-char"),
|
||||
pytest.param("", id="empty"),
|
||||
],
|
||||
)
|
||||
def test_unusable_iam_token_names_are_rejected(name):
|
||||
# The proxy reads a placeholder up to its first "}", so a brace in the name
|
||||
# resolves a different token than the one referenced — "a}b" would mint "a"
|
||||
# and leave "b}" as literal text.
|
||||
with pytest.raises(InvalidArgumentException, match="is not usable"):
|
||||
build_iam_config(
|
||||
cast(Any, {"tokens": {name: {"audience": "a", "token_type": "JWT-SVID"}}})
|
||||
)
|
||||
|
||||
# The update path takes any name from the callable, so it has to check again
|
||||
# at the point of interpolation.
|
||||
with pytest.raises(InvalidArgumentException, match="is not usable"):
|
||||
build_network_update_body(
|
||||
cast(
|
||||
Any,
|
||||
{
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {"A": ctx.iam.tokens[name]}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_ordinary_iam_token_names_are_accepted():
|
||||
iam = build_iam_config(
|
||||
{
|
||||
"tokens": {
|
||||
"aws.prod-1_x": Secret.iam_token(
|
||||
audience="sts.amazonaws.com", token_type="JWT-SVID"
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
assert iam is not None
|
||||
|
||||
body = build_network_config(
|
||||
{
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {"A": ctx.iam.tokens["aws.prod-1_x"]},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
iam,
|
||||
)
|
||||
assert body is not None
|
||||
assert body["rules"].to_dict() == {
|
||||
"api.internal.example.com": [
|
||||
{"transform": {"headers": {"A": "${e2b.identity.tokens.aws.prod-1_x}"}}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_update_network_resolves_transform_callables_without_iam():
|
||||
# The update payload carries no iam config, so the sandbox's registered
|
||||
# token names are unknown client-side and any name resolves to its
|
||||
# placeholder.
|
||||
body = build_network_update_body(
|
||||
{
|
||||
"allow_out": lambda ctx: list(ctx.rules.keys()),
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": lambda ctx: {
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {ctx.iam.tokens['aws']}",
|
||||
"X-Also": f"{ctx.iam.tokens.get('gcp')}",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert body.to_dict() == {
|
||||
"allowOut": ["api.internal.example.com"],
|
||||
"rules": {
|
||||
"api.internal.example.com": [
|
||||
{
|
||||
"transform": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${e2b.identity.tokens.aws}",
|
||||
"X-Also": "${e2b.identity.tokens.gcp}",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user