From b5a04abe1642f405a824c58b02a762060e5b32c8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:09:25 +0000 Subject: [PATCH 1/4] feat(sdk): add shell option to commands.run Co-Authored-By: mish@e2b.dev --- .changeset/commands-shell.md | 6 ++++ packages/js-sdk/src/sandbox/commands/index.ts | 22 +++++++++++++-- .../js-sdk/tests/sandbox/commands/run.test.ts | 28 ++++++++++++++++++- .../python-sdk/e2b/sandbox/commands/main.py | 20 ++++++++++++- .../e2b/sandbox_async/commands/command.py | 20 +++++++++++-- .../e2b/sandbox_sync/commands/command.py | 20 +++++++++++-- .../async/sandbox_async/commands/test_run.py | 21 +++++++++++++- .../sync/sandbox_sync/commands/test_run.py | 21 +++++++++++++- 8 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 .changeset/commands-shell.md diff --git a/.changeset/commands-shell.md b/.changeset/commands-shell.md new file mode 100644 index 0000000000..b8f6d19ecf --- /dev/null +++ b/.changeset/commands-shell.md @@ -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 ` (default stays `['/bin/bash', '-l', '-c']`) diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index dbebfe61d4..713fc70cec 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -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. */ @@ -68,6 +70,13 @@ export interface CommandStartOpts extends CommandRequestOpts { * @default `{}` */ envs?: Record + /** + * 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 `. + * + * @default `['/bin/bash', '-l', '-c']` + */ + shell?: string[] /** * Callback for command stdout output. */ @@ -456,6 +465,13 @@ export class Commands { ) } + const [shell, ...shellArgs] = opts?.shell ?? DEFAULT_SHELL + if (!shell) { + throw new InvalidArgumentError( + 'shell must contain at least the shell executable' + ) + } + const requestTimeoutMs = opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs @@ -467,10 +483,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, }, diff --git a/packages/js-sdk/tests/sandbox/commands/run.test.ts b/packages/js-sdk/tests/sandbox/commands/run.test.ts index 58abb59f4d..3d02ba8801 100644 --- a/packages/js-sdk/tests/sandbox/commands/run.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/run.test.ts @@ -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!' @@ -113,3 +117,25 @@ sandboxTest( expectTypeOf(defaults.stdout).toEqualTypeOf() } ) + +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) +}) diff --git a/packages/python-sdk/e2b/sandbox/commands/main.py b/packages/python-sdk/e2b/sandbox/commands/main.py index 67d52ed35e..74fed7112f 100644 --- a/packages/python-sdk/e2b/sandbox/commands/main.py +++ b/packages/python-sdk/e2b/sandbox/commands/main.py @@ -1,5 +1,7 @@ from dataclasses import dataclass -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple + +from e2b.exceptions import InvalidArgumentException @dataclass @@ -37,3 +39,19 @@ class ProcessInfo: """ Executed command working directory. """ + + +DEFAULT_SHELL = ["/bin/bash", "-l", "-c"] + + +def shell_command(shell: Optional[List[str]], cmd: str) -> Tuple[str, List[str]]: + """ + Split `shell` into the executable and its arguments, with `cmd` appended as the last argument. + """ + if shell is None: + shell = DEFAULT_SHELL + if not shell or not shell[0]: + raise InvalidArgumentException( + "shell must contain at least the shell executable" + ) + return shell[0], [*shell[1:], cmd] diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 2867f575ba..27fd8e43b5 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -24,7 +24,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 @@ -219,6 +219,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], + shell: Optional[List[str]] = None, ) -> CommandResult[None]: """ Start a new command and wait until it finishes executing. @@ -234,6 +235,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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `CommandResult` result of the command execution """ @@ -254,6 +256,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, + shell: Optional[List[str]] = None, ) -> CommandResult: """ Start a new command and wait until it finishes executing. @@ -269,6 +272,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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `CommandResult` result of the command execution """ @@ -289,6 +293,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[List[str]] = None, ) -> CommandResult[Optional[str]]: ... @overload @@ -306,6 +311,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], + shell: Optional[List[str]] = None, ) -> AsyncCommandHandle[None]: """ Start a new command and return a handle to interact with it. @@ -321,6 +327,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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `AsyncCommandHandle` handle to interact with the running command """ @@ -341,6 +348,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, + shell: Optional[List[str]] = None, ) -> AsyncCommandHandle: """ Start a new command and return a handle to interact with it. @@ -356,6 +364,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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `AsyncCommandHandle` handle to interact with the running command """ @@ -376,6 +385,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[List[str]] = None, ) -> AsyncCommandHandle[Optional[str]]: ... async def run( @@ -392,6 +402,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool = True, + shell: Optional[List[str]] = None, ): # Check version for stdin support if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: @@ -414,6 +425,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() @@ -430,14 +442,16 @@ async def _start( on_stdout: Optional[OutputHandler[Stdout]], on_stderr: Optional[OutputHandler[Stderr]], retain_output: bool, + shell: Optional[List[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, diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index 7cf96930dd..1f31986b4e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -35,7 +35,7 @@ from e2b.envd.client_sync import as_stream, create_rpc_client 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.commands.stream_input import stream_input_sync from e2b.sandbox_sync.commands.command_handle import CommandHandle @@ -228,6 +228,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], + shell: Optional[List[str]] = None, ) -> CommandResult[None]: """ Start a new command and wait until it finishes executing. @@ -243,6 +244,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `CommandResult` result of the command execution """ @@ -263,6 +265,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, + shell: Optional[List[str]] = None, ) -> CommandResult: """ Start a new command and wait until it finishes executing. @@ -278,6 +281,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `CommandResult` result of the command execution """ @@ -298,6 +302,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[List[str]] = None, ) -> CommandResult[Optional[str]]: ... @overload @@ -315,6 +320,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], + shell: Optional[List[str]] = None, ) -> CommandHandle[None]: """ Start a new command and return a handle to interact with it. @@ -328,6 +334,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `CommandHandle` handle to interact with the running command """ @@ -348,6 +355,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, + shell: Optional[List[str]] = None, ) -> CommandHandle: """ Start a new command and return a handle to interact with it. @@ -361,6 +369,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` :return: `CommandHandle` handle to interact with the running command """ @@ -381,6 +390,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[List[str]] = None, ) -> CommandHandle[Optional[str]]: ... def run( @@ -397,6 +407,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool = True, + shell: Optional[List[str]] = None, ): # Check version for stdin support if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: @@ -417,6 +428,7 @@ def run( timeout, request_timeout, retain_output, + shell, ) return ( @@ -438,14 +450,16 @@ def _start( timeout: Optional[float], request_timeout: Optional[float], retain_output: bool, + shell: Optional[List[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, diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py index 152eb15c17..182a2380a1 100644 --- a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py @@ -1,6 +1,6 @@ import pytest -from e2b import AsyncSandbox, TimeoutException +from e2b import AsyncSandbox, InvalidArgumentException, TimeoutException async def test_run(async_sandbox: AsyncSandbox): @@ -83,3 +83,22 @@ async def test_run_in_background_without_retaining_output( assert handle.stdout is None assert result.stdout is None assert result.stderr is None + + +async def test_run_with_custom_shell(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("print(6 * 7)", shell=["python3", "-c"]) + + assert cmd.exit_code == 0 + assert cmd.stdout == "42\n" + + +async def test_run_with_shell_without_args(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("$HOME", shell=["echo"]) + + assert cmd.exit_code == 0 + assert cmd.stdout == "$HOME\n" + + +async def test_run_with_empty_shell(async_sandbox: AsyncSandbox): + with pytest.raises(InvalidArgumentException): + await async_sandbox.commands.run("echo hello", shell=[]) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py index 3186e84fc6..90231a2e55 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py @@ -1,6 +1,6 @@ import pytest -from e2b import Sandbox, TimeoutException +from e2b import InvalidArgumentException, Sandbox, TimeoutException def test_run(sandbox: Sandbox): @@ -86,3 +86,22 @@ def test_run_in_background_without_retaining_output(sandbox: Sandbox): assert result.stdout is None assert result.stderr is None assert "".join(stdout) == "out\n" + + +def test_run_with_custom_shell(sandbox: Sandbox): + cmd = sandbox.commands.run("print(6 * 7)", shell=["python3", "-c"]) + + assert cmd.exit_code == 0 + assert cmd.stdout == "42\n" + + +def test_run_with_shell_without_args(sandbox: Sandbox): + cmd = sandbox.commands.run("$HOME", shell=["echo"]) + + assert cmd.exit_code == 0 + assert cmd.stdout == "$HOME\n" + + +def test_run_with_empty_shell(sandbox: Sandbox): + with pytest.raises(InvalidArgumentException): + sandbox.commands.run("echo hello", shell=[]) From e3de8d4ff78d6b3d662a9cfab4579d6da73a0846 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:23:34 +0000 Subject: [PATCH 2/4] fix(sdk): accept readonly shell sequences and clarify shell errors Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/sandbox/commands/index.ts | 5 +-- .../python-sdk/e2b/sandbox/commands/main.py | 6 ++-- .../e2b/sandbox_async/commands/command.py | 36 ++++++++++++------- .../e2b/sandbox_sync/commands/command.py | 25 ++++++------- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index 713fc70cec..8bbee18a6f 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -73,10 +73,11 @@ export interface CommandStartOpts extends CommandRequestOpts { /** * 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 `. + * Throws {@link InvalidArgumentError} if `shell` is empty or its first element (the executable) is empty. * * @default `['/bin/bash', '-l', '-c']` */ - shell?: string[] + shell?: readonly string[] /** * Callback for command stdout output. */ @@ -468,7 +469,7 @@ export class Commands { const [shell, ...shellArgs] = opts?.shell ?? DEFAULT_SHELL if (!shell) { throw new InvalidArgumentError( - 'shell must contain at least the shell executable' + "`shell` must start with the executable, e.g. `['/bin/sh', '-c']`. Omit `shell` to use the default `['/bin/bash', '-l', '-c']`." ) } diff --git a/packages/python-sdk/e2b/sandbox/commands/main.py b/packages/python-sdk/e2b/sandbox/commands/main.py index 74fed7112f..be5f23ee7c 100644 --- a/packages/python-sdk/e2b/sandbox/commands/main.py +++ b/packages/python-sdk/e2b/sandbox/commands/main.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from e2b.exceptions import InvalidArgumentException @@ -44,7 +44,7 @@ class ProcessInfo: DEFAULT_SHELL = ["/bin/bash", "-l", "-c"] -def shell_command(shell: Optional[List[str]], cmd: str) -> Tuple[str, List[str]]: +def shell_command(shell: Optional[Sequence[str]], cmd: str) -> Tuple[str, List[str]]: """ Split `shell` into the executable and its arguments, with `cmd` appended as the last argument. """ @@ -52,6 +52,6 @@ def shell_command(shell: Optional[List[str]], cmd: str) -> Tuple[str, List[str]] shell = DEFAULT_SHELL if not shell or not shell[0]: raise InvalidArgumentException( - "shell must contain at least the shell executable" + '`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] diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 27fd8e43b5..90906c2f80 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -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 @@ -219,7 +229,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandResult[None]: """ Start a new command and wait until it finishes executing. @@ -235,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `CommandResult` result of the command execution """ @@ -256,7 +266,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandResult: """ Start a new command and wait until it finishes executing. @@ -272,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `CommandResult` result of the command execution """ @@ -293,7 +303,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandResult[Optional[str]]: ... @overload @@ -311,7 +321,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> AsyncCommandHandle[None]: """ Start a new command and return a handle to interact with it. @@ -327,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `AsyncCommandHandle` handle to interact with the running command """ @@ -348,7 +358,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> AsyncCommandHandle: """ Start a new command and return a handle to interact with it. @@ -364,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `AsyncCommandHandle` handle to interact with the running command """ @@ -385,7 +395,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> AsyncCommandHandle[Optional[str]]: ... async def run( @@ -402,7 +412,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool = True, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ): # Check version for stdin support if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: @@ -442,7 +452,7 @@ async def _start( on_stdout: Optional[OutputHandler[Stdout]], on_stderr: Optional[OutputHandler[Stderr]], retain_output: bool, - shell: Optional[List[str]], + shell: Optional[Sequence[str]], ) -> AsyncCommandHandle[Optional[str]]: executable, args = shell_command(shell, cmd) events = as_stream( diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index 1f31986b4e..8422e6bf67 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -7,6 +7,7 @@ List, Literal, Optional, + Sequence, Union, overload, ) @@ -228,7 +229,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandResult[None]: """ Start a new command and wait until it finishes executing. @@ -244,7 +245,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `CommandResult` result of the command execution """ @@ -265,7 +266,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandResult: """ Start a new command and wait until it finishes executing. @@ -281,7 +282,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `CommandResult` result of the command execution """ @@ -302,7 +303,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandResult[Optional[str]]: ... @overload @@ -320,7 +321,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandHandle[None]: """ Start a new command and return a handle to interact with it. @@ -334,7 +335,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `CommandHandle` handle to interact with the running command """ @@ -355,7 +356,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandHandle: """ Start a new command and return a handle to interact with it. @@ -369,7 +370,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]` + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty :return: `CommandHandle` handle to interact with the running command """ @@ -390,7 +391,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ) -> CommandHandle[Optional[str]]: ... def run( @@ -407,7 +408,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool = True, - shell: Optional[List[str]] = None, + shell: Optional[Sequence[str]] = None, ): # Check version for stdin support if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: @@ -450,7 +451,7 @@ def _start( timeout: Optional[float], request_timeout: Optional[float], retain_output: bool, - shell: Optional[List[str]], + shell: Optional[Sequence[str]], ): executable, args = shell_command(shell, cmd) events = as_stream( From d2fc6cb587ea031e7dfd1ab2f40d9d41d6840832 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:26:00 +0000 Subject: [PATCH 3/4] fix(sdk): reject string shell in commands.run Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/sandbox/commands/index.ts | 7 ++++--- packages/js-sdk/tests/sandbox/commands/run.test.ts | 8 ++++++++ packages/python-sdk/e2b/sandbox/commands/main.py | 2 +- packages/python-sdk/e2b/sandbox_async/commands/command.py | 8 ++++---- packages/python-sdk/e2b/sandbox_sync/commands/command.py | 8 ++++---- .../tests/async/sandbox_async/commands/test_run.py | 5 +++++ .../tests/sync/sandbox_sync/commands/test_run.py | 5 +++++ 7 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index 8bbee18a6f..da6d1a0770 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -73,7 +73,7 @@ export interface CommandStartOpts extends CommandRequestOpts { /** * 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 `. - * Throws {@link InvalidArgumentError} if `shell` is empty or its first element (the executable) is empty. + * Throws {@link InvalidArgumentError} if `shell` is not an array, is empty, or its first element (the executable) is empty. * * @default `['/bin/bash', '-l', '-c']` */ @@ -466,8 +466,9 @@ export class Commands { ) } - const [shell, ...shellArgs] = opts?.shell ?? DEFAULT_SHELL - if (!shell) { + const shellOpt = opts?.shell ?? DEFAULT_SHELL + const [shell, ...shellArgs] = shellOpt + if (typeof shellOpt === 'string' || !shell) { throw new InvalidArgumentError( "`shell` must start with the executable, e.g. `['/bin/sh', '-c']`. Omit `shell` to use the default `['/bin/bash', '-l', '-c']`." ) diff --git a/packages/js-sdk/tests/sandbox/commands/run.test.ts b/packages/js-sdk/tests/sandbox/commands/run.test.ts index 3d02ba8801..d7e54759e8 100644 --- a/packages/js-sdk/tests/sandbox/commands/run.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/run.test.ts @@ -139,3 +139,11 @@ sandboxTest('run with empty shell', async ({ sandbox }) => { 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) +}) diff --git a/packages/python-sdk/e2b/sandbox/commands/main.py b/packages/python-sdk/e2b/sandbox/commands/main.py index be5f23ee7c..317fa99069 100644 --- a/packages/python-sdk/e2b/sandbox/commands/main.py +++ b/packages/python-sdk/e2b/sandbox/commands/main.py @@ -50,7 +50,7 @@ def shell_command(shell: Optional[Sequence[str]], cmd: str) -> Tuple[str, List[s """ if shell is None: shell = DEFAULT_SHELL - if not shell or not shell[0]: + if isinstance(shell, str) 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"]`.' ) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 90906c2f80..4c4b25988b 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -245,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `CommandResult` result of the command execution """ @@ -282,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `CommandResult` result of the command execution """ @@ -337,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `AsyncCommandHandle` handle to interact with the running command """ @@ -374,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `AsyncCommandHandle` handle to interact with the running command """ diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index 8422e6bf67..a47e27978b 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -245,7 +245,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `CommandResult` result of the command execution """ @@ -282,7 +282,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `CommandResult` result of the command execution """ @@ -335,7 +335,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `CommandHandle` handle to interact with the running command """ @@ -370,7 +370,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is empty or its first element is empty + :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty :return: `CommandHandle` handle to interact with the running command """ diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py index 182a2380a1..3fc856e290 100644 --- a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py @@ -102,3 +102,8 @@ async def test_run_with_shell_without_args(async_sandbox: AsyncSandbox): async def test_run_with_empty_shell(async_sandbox: AsyncSandbox): with pytest.raises(InvalidArgumentException): await async_sandbox.commands.run("echo hello", shell=[]) + + +async def test_run_with_string_shell(async_sandbox: AsyncSandbox): + with pytest.raises(InvalidArgumentException): + await async_sandbox.commands.run("echo hello", shell="/bin/sh") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py index 90231a2e55..be73fdb3ca 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py @@ -105,3 +105,8 @@ def test_run_with_shell_without_args(sandbox: Sandbox): def test_run_with_empty_shell(sandbox: Sandbox): with pytest.raises(InvalidArgumentException): sandbox.commands.run("echo hello", shell=[]) + + +def test_run_with_string_shell(sandbox: Sandbox): + with pytest.raises(InvalidArgumentException): + sandbox.commands.run("echo hello", shell="/bin/sh") From 7cca0d371c09a2b2231062684f1ff74e22327e57 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:30:59 +0000 Subject: [PATCH 4/4] fix(sdk): reject non-array shell in commands.run Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/sandbox/commands/index.ts | 4 ++-- packages/js-sdk/tests/sandbox/commands/run.test.ts | 8 ++++++++ packages/python-sdk/e2b/sandbox/commands/main.py | 7 ++++++- packages/python-sdk/e2b/sandbox_async/commands/command.py | 8 ++++---- packages/python-sdk/e2b/sandbox_sync/commands/command.py | 8 ++++---- .../tests/async/sandbox_async/commands/test_run.py | 5 +++++ .../tests/sync/sandbox_sync/commands/test_run.py | 5 +++++ 7 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index da6d1a0770..2dc4069507 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -467,12 +467,12 @@ export class Commands { } const shellOpt = opts?.shell ?? DEFAULT_SHELL - const [shell, ...shellArgs] = shellOpt - if (typeof shellOpt === 'string' || !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 diff --git a/packages/js-sdk/tests/sandbox/commands/run.test.ts b/packages/js-sdk/tests/sandbox/commands/run.test.ts index d7e54759e8..a236a3e8ec 100644 --- a/packages/js-sdk/tests/sandbox/commands/run.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/run.test.ts @@ -147,3 +147,11 @@ sandboxTest('run with string shell', async ({ sandbox }) => { }) ).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) +}) diff --git a/packages/python-sdk/e2b/sandbox/commands/main.py b/packages/python-sdk/e2b/sandbox/commands/main.py index 317fa99069..2f2d877af0 100644 --- a/packages/python-sdk/e2b/sandbox/commands/main.py +++ b/packages/python-sdk/e2b/sandbox/commands/main.py @@ -50,7 +50,12 @@ def shell_command(shell: Optional[Sequence[str]], cmd: str) -> Tuple[str, List[s """ if shell is None: shell = DEFAULT_SHELL - if isinstance(shell, str) or not shell or not shell[0]: + 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"]`.' ) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 4c4b25988b..a39384f413 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -245,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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 """ @@ -282,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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 """ @@ -337,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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 """ @@ -374,7 +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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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 """ diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index a47e27978b..9dc67b5fd5 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -245,7 +245,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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 """ @@ -282,7 +282,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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 """ @@ -335,7 +335,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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: `CommandHandle` handle to interact with the running command """ @@ -370,7 +370,7 @@ def run( :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time :param request_timeout: Not applied to this streaming call — both opening the stream and the stream itself are bounded by `timeout` (unlimited when `0`) :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 `. Defaults to `["/bin/bash", "-l", "-c"]`. Raises :class:`InvalidArgumentException` if `shell` is a string, is empty, or its first element is empty + :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 `. 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: `CommandHandle` handle to interact with the running command """ diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py index 3fc856e290..a53b102627 100644 --- a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py @@ -107,3 +107,8 @@ async def test_run_with_empty_shell(async_sandbox: AsyncSandbox): async def test_run_with_string_shell(async_sandbox: AsyncSandbox): with pytest.raises(InvalidArgumentException): await async_sandbox.commands.run("echo hello", shell="/bin/sh") + + +async def test_run_with_non_sequence_shell(async_sandbox: AsyncSandbox): + with pytest.raises(InvalidArgumentException): + await async_sandbox.commands.run("echo hello", shell={"/bin/sh", "-c"}) # type: ignore[arg-type] diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py index be73fdb3ca..493999d3b6 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py @@ -110,3 +110,8 @@ def test_run_with_empty_shell(sandbox: Sandbox): def test_run_with_string_shell(sandbox: Sandbox): with pytest.raises(InvalidArgumentException): sandbox.commands.run("echo hello", shell="/bin/sh") + + +def test_run_with_non_sequence_shell(sandbox: Sandbox): + with pytest.raises(InvalidArgumentException): + sandbox.commands.run("echo hello", shell={"/bin/sh", "-c"}) # type: ignore[arg-type]