Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/commands-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'e2b': minor
'@e2b/python-sdk': minor
---

Add `shell` option to `commands.run` to run the command in a custom shell, e.g. `shell: ['/bin/sh', '-c']` runs `/bin/sh -c <cmd>` (default stays `['/bin/bash', '-l', '-c']`)
24 changes: 21 additions & 3 deletions packages/js-sdk/src/sandbox/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import {
SandboxHealthCheck,
} from '../../envd/rpc'
import { ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE } from '../../envd/versions'
import { SandboxError } from '../../errors'
import { InvalidArgumentError, SandboxError } from '../../errors'
import { CommandHandle, CommandOutput, CommandResult } from './commandHandle'
import { CommandInputHandle, CommandStreamInputOpts } from './inputHandle'
export { Pty } from './pty'
export type { CommandStreamInputOpts }

const DEFAULT_SHELL = ['/bin/bash', '-l', '-c']

/**
* Options for sending a command request.
*/
Expand Down Expand Up @@ -68,6 +70,14 @@ export interface CommandStartOpts extends CommandRequestOpts {
* @default `{}`
*/
envs?: Record<string, string>
/**
* Shell used to run the command, as the executable followed by its arguments.
* The command is passed as the last argument, e.g. `['/bin/sh', '-c']` runs `/bin/sh -c <cmd>`.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
* Throws {@link InvalidArgumentError} if `shell` is not an array, is empty, or its first element (the executable) is empty.
*
* @default `['/bin/bash', '-l', '-c']`
*/
shell?: readonly string[]
/**
* Callback for command stdout output.
*/
Expand Down Expand Up @@ -456,6 +466,14 @@ export class Commands {
)
}

const shellOpt = opts?.shell ?? DEFAULT_SHELL
if (!Array.isArray(shellOpt) || !shellOpt[0]) {
throw new InvalidArgumentError(
"`shell` must start with the executable, e.g. `['/bin/sh', '-c']`. Omit `shell` to use the default `['/bin/bash', '-l', '-c']`."
)
}
const [shell, ...shellArgs] = shellOpt

const requestTimeoutMs =
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs

Expand All @@ -467,10 +485,10 @@ export class Commands {
const events = this.rpc.start(
{
process: {
cmd: '/bin/bash',
cmd: shell,
cwd: opts?.cwd,
envs: opts?.envs,
args: ['-l', '-c', cmd],
args: [...shellArgs, cmd],
},
stdin: opts?.stdin || false,
},
Expand Down
44 changes: 43 additions & 1 deletion packages/js-sdk/tests/sandbox/commands/run.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect, assert, expectTypeOf } from 'vitest'

import { sandboxTest } from '../../setup.js'
import { CommandStartOpts, TimeoutError } from '../../../src/index.js'
import {
CommandStartOpts,
InvalidArgumentError,
TimeoutError,
} from '../../../src/index.js'

sandboxTest('run', async ({ sandbox }) => {
const text = 'Hello, World!'
Expand Down Expand Up @@ -113,3 +117,41 @@ sandboxTest(
expectTypeOf(defaults.stdout).toEqualTypeOf<string>()
}
)

sandboxTest('run with custom shell', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('print(6 * 7)', {
shell: ['python3', '-c'],
})

assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, '42\n')
})

sandboxTest('run with shell without args', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('$HOME', { shell: ['echo'] })

assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, '$HOME\n')
})

sandboxTest('run with empty shell', async ({ sandbox }) => {
await expect(
sandbox.commands.run('echo hello', { shell: [] })
).rejects.toThrowError(InvalidArgumentError)
})

sandboxTest('run with string shell', async ({ sandbox }) => {
await expect(
sandbox.commands.run('echo hello', {
shell: '/bin/sh' as unknown as string[],
})
).rejects.toThrowError(InvalidArgumentError)
})

sandboxTest('run with non-array shell', async ({ sandbox }) => {
await expect(
sandbox.commands.run('echo hello', {
shell: new Set(['/bin/sh', '-c']) as unknown as string[],
})
).rejects.toThrowError(InvalidArgumentError)
})
25 changes: 24 additions & 1 deletion packages/python-sdk/e2b/sandbox/commands/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from dataclasses import dataclass
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Sequence, Tuple

from e2b.exceptions import InvalidArgumentException


@dataclass
Expand Down Expand Up @@ -37,3 +39,24 @@ class ProcessInfo:
"""
Executed command working directory.
"""


DEFAULT_SHELL = ["/bin/bash", "-l", "-c"]


def shell_command(shell: Optional[Sequence[str]], cmd: str) -> Tuple[str, List[str]]:
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
"""
Split `shell` into the executable and its arguments, with `cmd` appended as the last argument.
"""
if shell is None:
shell = DEFAULT_SHELL
if (
isinstance(shell, str)
or not isinstance(shell, Sequence)
or not shell
or not shell[0]
):
raise InvalidArgumentException(
'`shell` must start with the executable, e.g. `["/bin/sh", "-c"]`. Omit `shell` to use the default `["/bin/bash", "-l", "-c"]`.'
)
return shell[0], [*shell[1:], cmd]
32 changes: 28 additions & 4 deletions packages/python-sdk/e2b/sandbox_async/commands/command.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Union, overload
from typing import (
Any,
AsyncGenerator,
Dict,
List,
Literal,
Optional,
Sequence,
Union,
overload,
)

import httpx

Expand All @@ -24,7 +34,7 @@
from e2b.envd.client_async import as_stream, create_rpc_client, first_event
from e2b.envd.versions import ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE
from e2b.exceptions import SandboxException
from e2b.sandbox.commands.main import ProcessInfo
from e2b.sandbox.commands.main import ProcessInfo, shell_command
from e2b.sandbox.commands.command_handle import CommandResult
from e2b.sandbox_async.commands.command_input_handle import AsyncCommandInputHandle
from e2b.sandbox_async.commands.command_handle import AsyncCommandHandle, Stderr, Stdout
Expand Down Expand Up @@ -219,6 +229,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: Literal[False],
shell: Optional[Sequence[str]] = None,
) -> CommandResult[None]:
"""
Start a new command and wait until it finishes executing.
Expand All @@ -234,6 +245,7 @@ async def run(
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param request_timeout: Timeout for opening the stream in **seconds** — the wait until envd confirms with a start event. The running stream is bounded by `timeout`
:param retain_output: If `False`, stdout and stderr are not accumulated in memory and are `None` on the result. Use `on_stdout`/`on_stderr` to consume the output instead
:param shell: Shell used to run the command, as the executable followed by its arguments. The command is passed as the last argument, e.g. `["/bin/sh", "-c"]` runs `/bin/sh -c <cmd>`. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is not a sequence (or is a string), is empty, or its first element is empty

:return: `CommandResult` result of the command execution
"""
Expand All @@ -254,6 +266,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: Literal[True] = True,
shell: Optional[Sequence[str]] = None,
) -> CommandResult:
"""
Start a new command and wait until it finishes executing.
Expand All @@ -269,6 +282,7 @@ async def run(
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param request_timeout: Timeout for opening the stream in **seconds** — the wait until envd confirms with a start event. The running stream is bounded by `timeout`
:param retain_output: If `False`, stdout and stderr are not accumulated in memory and are `None` on the result. Use `on_stdout`/`on_stderr` to consume the output instead
:param shell: Shell used to run the command, as the executable followed by its arguments. The command is passed as the last argument, e.g. `["/bin/sh", "-c"]` runs `/bin/sh -c <cmd>`. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is not a sequence (or is a string), is empty, or its first element is empty

:return: `CommandResult` result of the command execution
"""
Expand All @@ -289,6 +303,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: bool,
shell: Optional[Sequence[str]] = None,
) -> CommandResult[Optional[str]]: ...

@overload
Expand All @@ -306,6 +321,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: Literal[False],
shell: Optional[Sequence[str]] = None,
) -> AsyncCommandHandle[None]:
"""
Start a new command and return a handle to interact with it.
Expand All @@ -321,6 +337,7 @@ async def run(
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param request_timeout: Timeout for opening the stream in **seconds** — the wait until envd confirms with a start event. The running stream is bounded by `timeout`
:param retain_output: If `False`, stdout and stderr are not accumulated in memory and are `None` on the result. Use `on_stdout`/`on_stderr` to consume the output instead
:param shell: Shell used to run the command, as the executable followed by its arguments. The command is passed as the last argument, e.g. `["/bin/sh", "-c"]` runs `/bin/sh -c <cmd>`. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is not a sequence (or is a string), is empty, or its first element is empty

:return: `AsyncCommandHandle` handle to interact with the running command
"""
Expand All @@ -341,6 +358,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: Literal[True] = True,
shell: Optional[Sequence[str]] = None,
) -> AsyncCommandHandle:
"""
Start a new command and return a handle to interact with it.
Expand All @@ -356,6 +374,7 @@ async def run(
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param request_timeout: Timeout for opening the stream in **seconds** — the wait until envd confirms with a start event. The running stream is bounded by `timeout`
:param retain_output: If `False`, stdout and stderr are not accumulated in memory and are `None` on the result. Use `on_stdout`/`on_stderr` to consume the output instead
:param shell: Shell used to run the command, as the executable followed by its arguments. The command is passed as the last argument, e.g. `["/bin/sh", "-c"]` runs `/bin/sh -c <cmd>`. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is not a sequence (or is a string), is empty, or its first element is empty

:return: `AsyncCommandHandle` handle to interact with the running command
"""
Expand All @@ -376,6 +395,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: bool,
shell: Optional[Sequence[str]] = None,
) -> AsyncCommandHandle[Optional[str]]: ...

async def run(
Expand All @@ -392,6 +412,7 @@ async def run(
request_timeout: Optional[float] = None,
*,
retain_output: bool = True,
shell: Optional[Sequence[str]] = None,
):
# Check version for stdin support
if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN:
Expand All @@ -414,6 +435,7 @@ async def run(
on_stdout=on_stdout,
on_stderr=on_stderr,
retain_output=retain_output,
shell=shell,
)

return proc if background else await proc.wait()
Expand All @@ -430,14 +452,16 @@ async def _start(
on_stdout: Optional[OutputHandler[Stdout]],
on_stderr: Optional[OutputHandler[Stderr]],
retain_output: bool,
shell: Optional[Sequence[str]],
) -> AsyncCommandHandle[Optional[str]]:
executable, args = shell_command(shell, cmd)
events = as_stream(
self._rpc.start(
process_pb.StartRequest(
process=process_pb.ProcessConfig(
cmd="/bin/bash",
cmd=executable,
envs=envs,
args=["-l", "-c", cmd],
args=args,
cwd=cwd,
),
stdin=stdin,
Expand Down
Loading
Loading