Skip to content
Open
22 changes: 22 additions & 0 deletions .changeset/python-js-sdk-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"e2b": patch
"@e2b/python-sdk": patch
"@e2b/cli": patch
---

fix: align behavior between the JS and Python SDKs

Python SDK:

- `commands.send_stdin` and `CommandHandle.send_stdin` now accept `bytes` in addition to `str`, and the handle's `send_stdin` / `close_stdin` now accept a `request_timeout`.
- `git.reset` now accepts a typed `GitResetMode` and its validation error matches the JS SDK wording/ordering. `GitResetMode` is now exported.
- `sandbox_url` is now propagated through `get_api_params`.
- `Template.from_image()` now raises when only one of `username` / `password` is provided.

JS SDK:

- `Sandbox.getInfo()` now includes `sandboxDomain`; the `getFullInfo` method was removed (use `getInfo`), matching the Python SDK's single `get_info`.
- `Sandbox.getMetrics()` now returns `[]` in debug mode, matching the Python SDK. The debug short-circuit for `getMetrics` / `kill` is implemented on both the instance and static methods, so it applies consistently whether called as `Sandbox.kill(sandboxId)` or `sandbox.kill()`.
- `Template.fromImage()` now requires both `username` and `password` when registry credentials are provided.
- `Template.getBuildStatus()` now defaults `logsOffset` to `0`.
- `requestTimeoutMs: 0` now explicitly disables the request timeout.
3 changes: 1 addition & 2 deletions packages/cli/src/commands/sandbox/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ export const infoCommand = new commander.Command('info')
try {
const format = options.format || 'pretty'
const apiKey = ensureAPIKey()
const info = await Sandbox.getFullInfo(sandboxID, { apiKey })
delete info.envdAccessToken
const info = await Sandbox.getInfo(sandboxID, { apiKey })

if (format === 'pretty') {
renderPrettyInfo(info as unknown as Record<string, unknown>)
Expand Down
3 changes: 3 additions & 0 deletions packages/js-sdk/src/connectionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface ConnectionOpts {
/**
* Timeout for requests to the API in **milliseconds**.
*
* Set to `0` to disable the request timeout.
*
* @default 60_000 // 60 seconds
*/
requestTimeoutMs?: number
Expand Down Expand Up @@ -94,6 +96,7 @@ export function buildRequestSignal(
requestTimeoutMs: number | undefined,
userSignal: AbortSignal | undefined
): AbortSignal | undefined {
// `0` (and `undefined`) disable the request timeout.
const timeoutSignal = requestTimeoutMs
? AbortSignal.timeout(requestTimeoutMs)
: undefined
Expand Down
7 changes: 6 additions & 1 deletion packages/js-sdk/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ export class Sandbox extends SandboxApi {
*/
async kill(opts?: Pick<SandboxOpts, 'requestTimeoutMs' | 'signal'>) {
if (this.connectionConfig.debug) {
// Skip killing in debug mode
// Skip killing the sandbox in debug mode
return
}

Expand Down Expand Up @@ -736,6 +736,11 @@ export class Sandbox extends SandboxApi {
* @returns List of sandbox metrics containing CPU, memory and disk usage information.
*/
async getMetrics(opts?: SandboxMetricsOpts) {
if (this.connectionConfig.debug) {
// Skip getting the metrics in debug mode
return []
}

if (this.envdApi.version) {
if (compareVersions(this.envdApi.version, '0.1.5') < 0) {
throw new SandboxError(
Expand Down
134 changes: 71 additions & 63 deletions packages/js-sdk/src/sandbox/sandboxApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,11 @@ export interface SandboxInfo {
* Volume mounts for the sandbox.
*/
volumeMounts?: Array<{ name: string; path: string }>

/**
* Sandbox domain.
*/
sandboxDomain?: string
}

/**
Expand Down Expand Up @@ -650,6 +655,12 @@ export class SandboxApi {
opts?: SandboxApiOpts
): Promise<boolean> {
const config = new ConnectionConfig(opts)

if (config.debug) {
// Skip killing the sandbox in debug mode
return true
}

const client = new ApiClient(config)

const res = await client.api.DELETE('/sandboxes/{sandboxID}', {
Expand Down Expand Up @@ -685,11 +696,61 @@ export class SandboxApi {
sandboxId: string,
opts?: SandboxApiOpts
): Promise<SandboxInfo> {
const fullInfo = await this.getFullInfo(sandboxId, opts)
delete fullInfo.envdAccessToken
delete fullInfo.sandboxDomain
const config = new ConnectionConfig(opts)
const client = new ApiClient(config)

const res = await client.api.GET('/sandboxes/{sandboxID}', {
params: {
path: {
sandboxID: sandboxId,
},
},
signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal),
})

if (res.error?.code === 404) {
throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`)
}

const err = handleApiError(res)
if (err) {
throw err
}

if (!res.data) {
throw new Error('Sandbox not found')
}

return fullInfo
return {
sandboxId: res.data.sandboxID,
templateId: res.data.templateID,
...(res.data.alias && { name: res.data.alias }),
metadata: res.data.metadata ?? {},
allowInternetAccess: res.data.allowInternetAccess ?? undefined,
envdVersion: res.data.envdVersion,
startedAt: new Date(res.data.startedAt),
endAt: new Date(res.data.endAt),
state: res.data.state,
cpuCount: res.data.cpuCount,
memoryMB: res.data.memoryMB,
network: res.data.network
? {
allowOut: res.data.network.allowOut,
denyOut: res.data.network.denyOut,
rules: res.data.network.rules ?? undefined,
allowPublicTraffic: res.data.network.allowPublicTraffic,
maskRequestHost: res.data.network.maskRequestHost,
}
: undefined,
lifecycle: res.data.lifecycle
? {
onTimeout: res.data.lifecycle.onTimeout,
autoResume: res.data.lifecycle.autoResume,
}
: undefined,
sandboxDomain: res.data.domain || undefined,
volumeMounts: res.data.volumeMounts ?? [],
}
}

/**
Expand All @@ -705,6 +766,12 @@ export class SandboxApi {
opts?: SandboxMetricsOpts
): Promise<SandboxMetrics[]> {
const config = new ConnectionConfig(opts)

if (config.debug) {
// Skip getting the metrics in debug mode
return []
}

const client = new ApiClient(config)

// JS timestamp is in milliseconds, convert to unix (seconds)
Expand Down Expand Up @@ -822,65 +889,6 @@ export class SandboxApi {
}
}

static async getFullInfo(sandboxId: string, opts?: SandboxApiOpts) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe a reason to make major instead of minor but not end of world

const config = new ConnectionConfig(opts)
const client = new ApiClient(config)

const res = await client.api.GET('/sandboxes/{sandboxID}', {
params: {
path: {
sandboxID: sandboxId,
},
},
signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal),
})

if (res.error?.code === 404) {
throw new SandboxNotFoundError(`Sandbox ${sandboxId} not found`)
}

const err = handleApiError(res)
if (err) {
throw err
}

if (!res.data) {
throw new Error('Sandbox not found')
}

return {
sandboxId: res.data.sandboxID,
templateId: res.data.templateID,
...(res.data.alias && { name: res.data.alias }),
metadata: res.data.metadata ?? {},
allowInternetAccess: res.data.allowInternetAccess ?? undefined,
envdVersion: res.data.envdVersion,
envdAccessToken: res.data.envdAccessToken,
startedAt: new Date(res.data.startedAt),
endAt: new Date(res.data.endAt),
state: res.data.state,
cpuCount: res.data.cpuCount,
memoryMB: res.data.memoryMB,
network: res.data.network
? {
allowOut: res.data.network.allowOut,
denyOut: res.data.network.denyOut,
rules: res.data.network.rules ?? undefined,
allowPublicTraffic: res.data.network.allowPublicTraffic,
maskRequestHost: res.data.network.maskRequestHost,
}
: undefined,
lifecycle: res.data.lifecycle
? {
onTimeout: res.data.lifecycle.onTimeout,
autoResume: res.data.lifecycle.autoResume,
}
: undefined,
sandboxDomain: res.data.domain || undefined,
volumeMounts: res.data.volumeMounts ?? [],
}
}

/**
* Pause the sandbox specified by sandbox ID.
*
Expand Down
11 changes: 9 additions & 2 deletions packages/js-sdk/src/template/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PathLike } from 'node:fs'
import { ApiClient } from '../api'
import { ConnectionConfig, ConnectionOpts } from '../connectionConfig'
import { BuildError } from '../errors'
import { BuildError, InvalidArgumentError } from '../errors'
import { runtime } from '../utils'
import {
assignTags,
Expand Down Expand Up @@ -270,7 +270,7 @@ export class TemplateBase
{
templateID: data.templateId,
buildID: data.buildId,
logsOffset: options?.logsOffset,
logsOffset: options?.logsOffset ?? 0,
},
config.getSignal(undefined, options?.signal)
)
Expand Down Expand Up @@ -452,6 +452,13 @@ export class TemplateBase

// Set the registry config if provided
if (credentials) {
if (!credentials.username || !credentials.password) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should do credential check before doing the work above

throw new InvalidArgumentError(
'Both username and password are required when providing registry credentials',
getCallerFrame(STACK_TRACE_DEPTH - 1)
)
}

this.registryConfig = {
type: 'registry',
username: credentials.username,
Expand Down
25 changes: 25 additions & 0 deletions packages/js-sdk/tests/connectionConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,31 @@ test('getSignal returns undefined when no timeout and no signal', () => {
assert.equal(signal, undefined)
})

test('requestTimeoutMs 0 from the config disables the timeout', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
// The stored value is kept as 0 (not replaced by the default).
assert.equal(config.requestTimeoutMs, 0)
// getSignal() with no per-call arg falls back to the stored 0, which must
// NOT produce a timeout signal.
assert.equal(config.getSignal(), undefined)
// With only a user signal, no timeout signal is layered on top.
const controller = new AbortController()
assert.strictEqual(
config.getSignal(undefined, controller.signal),
controller.signal
)
})

test('setupRequestController with config timeout 0 never auto-aborts', async () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const { controller } = setupRequestController(
config.requestTimeoutMs,
undefined
)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, false)
})

test('setupRequestController aborts when user signal aborts', () => {
const userController = new AbortController()
const { controller } = setupRequestController(0, userController.signal)
Expand Down
21 changes: 21 additions & 0 deletions packages/js-sdk/tests/sandbox/commands/sendStdin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ sandboxTest('send stdin to process', async ({ sandbox }) => {
assert.equal(cmd.stdout, text)
})

sandboxTest('send Uint8Array stdin to process', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})

await sandbox.commands.sendStdin(cmd.pid, new TextEncoder().encode(text))

for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}

await cmd.kill()

assert.equal(cmd.stdout, text)
})

sandboxTest('send stdin via command handle', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
Expand Down
44 changes: 44 additions & 0 deletions packages/js-sdk/tests/sandbox/git/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test, expect } from 'vitest'

import { Git } from '../../../src/sandbox/git'
import type { Commands } from '../../../src/sandbox/commands'
import { InvalidArgumentError } from '../../../src/errors'

// Stub command runner that fails if a git command is actually executed —
// validation must throw before reaching it.
const failingCommands = {
run: () => {
throw new Error('commands.run should not be called')
},
} as unknown as Commands

test('git.reset throws InvalidArgumentError on an invalid mode', async () => {
const git = new Git(failingCommands)
await expect(
// @ts-expect-error - testing runtime validation with an invalid mode
git.reset('/repo', { mode: 'bogus' })
).rejects.toThrow(InvalidArgumentError)
})

test('git.reset accepts a valid mode', async () => {
const git = new Git(failingCommands)
// A valid mode must pass validation and reach the (stubbed) command runner.
await expect(git.reset('/repo', { mode: 'hard' })).rejects.toThrow(
'commands.run should not be called'
)
})

test('git.remoteAdd throws InvalidArgumentError when name or url is missing', async () => {
const git = new Git(failingCommands)
await expect(
git.remoteAdd('/repo', '', 'https://example.com')
).rejects.toThrow(InvalidArgumentError)
await expect(git.remoteAdd('/repo', 'origin', '')).rejects.toThrow(
InvalidArgumentError
)
})

test('git.remoteGet throws InvalidArgumentError when name is missing', async () => {
const git = new Git(failingCommands)
await expect(git.remoteGet('/repo', '')).rejects.toThrow(InvalidArgumentError)
})
7 changes: 7 additions & 0 deletions packages/js-sdk/tests/template/stacktrace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ buildTemplateTest('traces on fromGCPRegistry', async ({ buildTemplate }) => {
}, 'fromGCPRegistry')
})

buildTemplateTest('traces on fromImage credentials', async () => {
await expectToThrowAndCheckTrace(async () => {
// @ts-expect-error - testing runtime validation with partial credentials
Template().fromImage('ubuntu:22.04', { username: 'user' })
}, 'fromImage')
})

buildTemplateTest('traces on copy', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().copy(nonExistentPath, nonExistentPath)
Expand Down
Loading
Loading