Skip to content

Commit 9e3e52b

Browse files
mishushakovclaude
andauthored
Add --user/--cwd/--env terminal flags to sandbox create & connect (#1501)
Exposes `--user`, `--cwd`, and repeatable `--env KEY=VALUE` flags on `e2b sandbox create` (and the deprecated `spawn` alias) and `e2b sandbox connect`, forwarding them to the underlying PTY session so the connected terminal starts as the given user, in the given working directory, and with the given environment variables. The SDKs already supported these PTY options — this just wires them through the CLI. The `--env` arg parser is extracted into a shared `src/utils/env.ts` and reused across `create`, `connect`, and `exec`. Added unit tests for the parser and CLI tests covering the new flags; a changeset is included for `@e2b/cli`. ## Usage ```bash # Start the terminal as root, in /app, with custom env vars e2b sandbox create base --user root --cwd /app --env FOO=bar --env TOKEN=abc123 # Same flags when attaching to an already-running sandbox e2b sandbox connect <sandboxID> --user root --cwd /app --env FOO=bar ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 05b7a79 commit 9e3e52b

8 files changed

Lines changed: 200 additions & 27 deletions

File tree

.changeset/cli-pty-user-cwd-env.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@e2b/cli": patch
3+
---
4+
5+
Add `--user`, `--cwd`, and `--env` flags to `e2b sandbox create` (and the deprecated `spawn` alias) and `e2b sandbox connect`. These are forwarded to the underlying PTY session so the connected terminal starts as the given user, in the given working directory, and with the given environment variables. `--env` accepts repeatable `KEY=VALUE` pairs.

packages/cli/src/commands/sandbox/connect.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,67 @@
11
import * as e2b from 'e2b'
22
import * as commander from 'commander'
33

4-
import { spawnConnectedTerminal } from 'src/terminal'
4+
import { spawnConnectedTerminal, TerminalOpts } from 'src/terminal'
55
import { asBold, asPrimary } from 'src/utils/format'
66
import { ensureAPIKey } from '../../api'
7+
import { parseEnv } from 'src/utils/env'
78
import { printDashboardSandboxInspectUrl } from 'src/utils/urls'
89

910
export const connectCommand = new commander.Command('connect')
1011
.description('connect terminal to already running sandbox')
1112
.argument('<sandboxID>', `connect to sandbox with ${asBold('<sandboxID>')}`)
13+
.option('-u, --user <user>', 'user to start the terminal session as')
14+
.option('-c, --cwd <dir>', 'working directory for the terminal session')
15+
.option(
16+
'-e, --env <KEY=VALUE>',
17+
'set environment variable for the terminal session (repeatable)',
18+
parseEnv,
19+
{} as Record<string, string>
20+
)
1221
.alias('cn')
13-
.action(async (sandboxID: string) => {
14-
try {
15-
const apiKey = ensureAPIKey()
22+
.action(
23+
async (
24+
sandboxID: string,
25+
opts: { user?: string; cwd?: string; env?: Record<string, string> }
26+
) => {
27+
try {
28+
const apiKey = ensureAPIKey()
29+
30+
if (!sandboxID) {
31+
console.error('You need to specify sandbox ID')
32+
process.exit(1)
33+
}
1634

17-
if (!sandboxID) {
18-
console.error('You need to specify sandbox ID')
35+
await connectToSandbox({
36+
apiKey,
37+
sandboxID,
38+
terminal: {
39+
user: opts.user,
40+
cwd: opts.cwd,
41+
envs:
42+
opts.env && Object.keys(opts.env).length > 0
43+
? opts.env
44+
: undefined,
45+
},
46+
})
47+
// We explicitly call exit because the sandbox is keeping the program alive.
48+
// We also don't want to call sandbox.close because that would disconnect other users from the edit session.
49+
process.exit(0)
50+
} catch (err: any) {
51+
console.error(err)
1952
process.exit(1)
2053
}
21-
22-
await connectToSandbox({ apiKey, sandboxID })
23-
// We explicitly call exit because the sandbox is keeping the program alive.
24-
// We also don't want to call sandbox.close because that would disconnect other users from the edit session.
25-
process.exit(0)
26-
} catch (err: any) {
27-
console.error(err)
28-
process.exit(1)
2954
}
30-
})
55+
)
3156

3257
async function connectToSandbox({
3358
apiKey,
3459
sandboxID,
60+
terminal,
3561
}: {
3662
apiKey: string
3763
sandboxID: string
64+
terminal?: TerminalOpts
3865
}) {
3966
const sandbox = await e2b.Sandbox.connect(sandboxID, { apiKey })
4067

@@ -43,7 +70,7 @@ async function connectToSandbox({
4370
console.log(
4471
`Terminal connecting to sandbox ${asPrimary(`${sandbox.sandboxId}`)}`
4572
)
46-
await spawnConnectedTerminal(sandbox)
73+
await spawnConnectedTerminal(sandbox, terminal)
4774
console.log(
4875
`Closing terminal connection to sandbox ${asPrimary(sandbox.sandboxId)}`
4976
)

packages/cli/src/commands/sandbox/create.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import * as commander from 'commander'
33
import * as path from 'path'
44

55
import { ensureAPIKey } from 'src/api'
6-
import { spawnConnectedTerminal } from 'src/terminal'
6+
import { spawnConnectedTerminal, TerminalOpts } from 'src/terminal'
77
import { asBold, asFormattedSandboxTemplate } from 'src/utils/format'
88
import { getRoot } from '../../utils/filesystem'
99
import { getConfigPath, loadConfig } from '../../config'
1010
import fs from 'fs'
1111
import { configOption, pathOption } from '../../options'
12+
import { parseEnv } from '../../utils/env'
1213
import { printDashboardSandboxInspectUrl } from 'src/utils/urls'
1314

1415
type SandboxLifecycle = {
@@ -42,6 +43,14 @@ export function createCommand(
4243
'enable sandbox auto-resume, requires --lifecycle.ontimeout pause'
4344
)
4445
.option('--timeout <seconds>', 'sandbox timeout in seconds', parseTimeout)
46+
.option('-u, --user <user>', 'user to start the terminal session as')
47+
.option('-c, --cwd <dir>', 'working directory for the terminal session')
48+
.option(
49+
'-e, --env <KEY=VALUE>',
50+
'set environment variable for the terminal session (repeatable)',
51+
parseEnv,
52+
{} as Record<string, string>
53+
)
4554
.alias(alias)
4655
.action(
4756
async (
@@ -54,6 +63,9 @@ export function createCommand(
5463
'lifecycle.ontimeout'?: SandboxLifecycle['onTimeout']
5564
'lifecycle.autoresume'?: boolean
5665
timeout?: number
66+
user?: string
67+
cwd?: string
68+
env?: Record<string, string>
5769
}
5870
) => {
5971
if (deprecated) {
@@ -109,6 +121,14 @@ export function createCommand(
109121
sandbox,
110122
template: { templateID },
111123
timeoutMs: opts.timeout,
124+
terminal: {
125+
user: opts.user,
126+
cwd: opts.cwd,
127+
envs:
128+
opts.env && Object.keys(opts.env).length > 0
129+
? opts.env
130+
: undefined,
131+
},
112132
})
113133
} else {
114134
console.log(
@@ -177,10 +197,12 @@ export async function connectSandbox({
177197
sandbox,
178198
template,
179199
timeoutMs,
200+
terminal,
180201
}: {
181202
sandbox: e2b.Sandbox
182203
template: Pick<e2b.components['schemas']['Template'], 'templateID'>
183204
timeoutMs?: number
205+
terminal?: TerminalOpts
184206
}) {
185207
// keep-alive loop — track the in-flight promise so we can await it on shutdown
186208
let pendingKeepAlive: Promise<void> = Promise.resolve()
@@ -195,7 +217,7 @@ export async function connectSandbox({
195217
)} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}`
196218
)
197219
try {
198-
await spawnConnectedTerminal(sandbox)
220+
await spawnConnectedTerminal(sandbox, terminal)
199221
} finally {
200222
clearInterval(intervalId)
201223
await pendingKeepAlive.catch(() => {})

packages/cli/src/commands/sandbox/exec.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as commander from 'commander'
77

88
import { ensureAPIKey } from '../../api'
99
import { setupSignalHandlers } from 'src/utils/signal'
10+
import { parseEnv } from 'src/utils/env'
1011
import { buildCommand, isPipedStdin, streamStdinChunks } from './exec_helpers'
1112

1213
interface ExecOptions {
@@ -27,13 +28,7 @@ export const execCommand = new commander.Command('exec')
2728
.option(
2829
'-e, --env <KEY=VALUE>',
2930
'set environment variable (repeatable)',
30-
(value: string, previous: Record<string, string>) => {
31-
const [key, ...rest] = value.split('=')
32-
if (key && rest.length > 0) {
33-
previous[key] = rest.join('=')
34-
}
35-
return previous
36-
},
31+
parseEnv,
3732
{} as Record<string, string>
3833
)
3934
.alias('ex')

packages/cli/src/terminal.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ function getStdoutSize() {
99
}
1010
}
1111

12-
export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) {
12+
export interface TerminalOpts {
13+
user?: e2b.Username
14+
cwd?: string
15+
envs?: Record<string, string>
16+
}
17+
18+
export async function spawnConnectedTerminal(
19+
sandbox: e2b.Sandbox,
20+
opts: TerminalOpts = {}
21+
) {
1322
// Clear local terminal emulator before starting terminal
1423
// process.stdout.write('\x1b[2J\x1b[0f')
1524

@@ -22,6 +31,9 @@ export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) {
2231
},
2332
...getStdoutSize(),
2433
timeoutMs: 0,
34+
...(opts.user !== undefined ? { user: opts.user } : {}),
35+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
36+
...(opts.envs !== undefined ? { envs: opts.envs } : {}),
2537
})
2638

2739
const inputQueue = new BatchedQueue<Buffer>(async (batch) => {

packages/cli/src/utils/env.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Commander arg parser for repeatable `--env KEY=VALUE` flags.
3+
*
4+
* Accumulates parsed pairs into `previous` so the flag can be passed multiple
5+
* times. Values may contain `=` (only the first `=` separates key from value).
6+
*/
7+
export function parseEnv(
8+
value: string,
9+
previous: Record<string, string>
10+
): Record<string, string> {
11+
const [key, ...rest] = value.split('=')
12+
if (key && rest.length > 0) {
13+
previous[key] = rest.join('=')
14+
}
15+
return previous
16+
}

packages/cli/tests/commands/sandbox/create_lifecycle.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,79 @@ describe('sandbox create lifecycle options', () => {
137137
{ from: 'user' }
138138
)
139139

140-
expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox)
140+
expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox, {
141+
user: undefined,
142+
cwd: undefined,
143+
envs: undefined,
144+
})
141145
expect(sandbox.setTimeout).toHaveBeenCalledWith(120_000)
142146
expect(sandbox.setTimeout).not.toHaveBeenCalledWith(1_000)
143147
expect(exitSpy).toHaveBeenCalledWith(0)
144148
vi.useRealTimers()
145149
})
146150

151+
test('passes user, cwd and envs to the connected terminal', async () => {
152+
const exitSpy = vi
153+
.spyOn(process, 'exit')
154+
.mockImplementation((() => undefined) as never)
155+
const sandbox = {
156+
sandboxId: 'sandbox-id',
157+
setTimeout: vi.fn().mockResolvedValue(undefined),
158+
}
159+
mocks.create.mockResolvedValue(sandbox)
160+
161+
const { createCommand } = await import(
162+
'../../../src/commands/sandbox/create'
163+
)
164+
await createCommand('create', 'cr', false).parseAsync(
165+
[
166+
'base',
167+
'--user',
168+
'root',
169+
'--cwd',
170+
'/app',
171+
'--env',
172+
'FOO=bar',
173+
'--env',
174+
'BAZ=qux=quux',
175+
],
176+
{ from: 'user' }
177+
)
178+
179+
expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox, {
180+
user: 'root',
181+
cwd: '/app',
182+
envs: { FOO: 'bar', BAZ: 'qux=quux' },
183+
})
184+
expect(exitSpy).toHaveBeenCalledWith(0)
185+
})
186+
187+
test('omits empty envs when no --env flags are provided', async () => {
188+
const exitSpy = vi
189+
.spyOn(process, 'exit')
190+
.mockImplementation((() => undefined) as never)
191+
const sandbox = {
192+
sandboxId: 'sandbox-id',
193+
setTimeout: vi.fn().mockResolvedValue(undefined),
194+
}
195+
mocks.create.mockResolvedValue(sandbox)
196+
197+
const { createCommand } = await import(
198+
'../../../src/commands/sandbox/create'
199+
)
200+
await createCommand('create', 'cr', false).parseAsync(
201+
['base', '--user', 'root'],
202+
{ from: 'user' }
203+
)
204+
205+
expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox, {
206+
user: 'root',
207+
cwd: undefined,
208+
envs: undefined,
209+
})
210+
expect(exitSpy).toHaveBeenCalledWith(0)
211+
})
212+
147213
test('rejects autoresume without pause on timeout', async () => {
148214
const exitSpy = vi
149215
.spyOn(process, 'exit')
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { parseEnv } from '../../src/utils/env'
4+
5+
describe('parseEnv', () => {
6+
test('accumulates repeated KEY=VALUE pairs', () => {
7+
const acc: Record<string, string> = {}
8+
parseEnv('FOO=bar', acc)
9+
parseEnv('BAZ=qux', acc)
10+
expect(acc).toEqual({ FOO: 'bar', BAZ: 'qux' })
11+
})
12+
13+
test('keeps "=" inside the value', () => {
14+
const acc: Record<string, string> = {}
15+
parseEnv('TOKEN=a=b=c', acc)
16+
expect(acc).toEqual({ TOKEN: 'a=b=c' })
17+
})
18+
19+
test('ignores entries without a value', () => {
20+
const acc: Record<string, string> = {}
21+
parseEnv('NOVALUE', acc)
22+
expect(acc).toEqual({})
23+
})
24+
25+
test('preserves an empty string value', () => {
26+
const acc: Record<string, string> = {}
27+
parseEnv('EMPTY=', acc)
28+
expect(acc).toEqual({ EMPTY: '' })
29+
})
30+
})

0 commit comments

Comments
 (0)