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..2dc4069507 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,14 @@ 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 `. + * 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. */ @@ -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 @@ -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, }, diff --git a/packages/js-sdk/tests/sandbox/commands/run.test.ts b/packages/js-sdk/tests/sandbox/commands/run.test.ts index 58abb59f4d..a236a3e8ec 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,41 @@ 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) +}) + +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) +}) diff --git a/packages/python-sdk/e2b/sandbox/commands/main.py b/packages/python-sdk/e2b/sandbox/commands/main.py index 67d52ed35e..2f2d877af0 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, Sequence, Tuple + +from e2b.exceptions import InvalidArgumentException @dataclass @@ -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]]: + """ + 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] diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 2867f575ba..a39384f413 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 @@ -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 @@ -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. @@ -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 `. 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 """ @@ -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. @@ -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 `. 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 """ @@ -289,6 +303,7 @@ async def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[Sequence[str]] = None, ) -> CommandResult[Optional[str]]: ... @overload @@ -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. @@ -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 `. 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 """ @@ -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. @@ -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 `. 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 """ @@ -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( @@ -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: @@ -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() @@ -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, diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index 7cf96930dd..9dc67b5fd5 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, ) @@ -35,7 +36,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 +229,7 @@ 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. @@ -243,6 +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 not a sequence (or is a string), is empty, or its first element is empty :return: `CommandResult` result of the command execution """ @@ -263,6 +266,7 @@ 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. @@ -278,6 +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 not a sequence (or is a string), is empty, or its first element is empty :return: `CommandResult` result of the command execution """ @@ -298,6 +303,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[Sequence[str]] = None, ) -> CommandResult[Optional[str]]: ... @overload @@ -315,6 +321,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[False], + shell: Optional[Sequence[str]] = None, ) -> CommandHandle[None]: """ Start a new command and return a handle to interact with it. @@ -328,6 +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 not a sequence (or is a string), is empty, or its first element is empty :return: `CommandHandle` handle to interact with the running command """ @@ -348,6 +356,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: Literal[True] = True, + shell: Optional[Sequence[str]] = None, ) -> CommandHandle: """ Start a new command and return a handle to interact with it. @@ -361,6 +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 not a sequence (or is a string), is empty, or its first element is empty :return: `CommandHandle` handle to interact with the running command """ @@ -381,6 +391,7 @@ def run( request_timeout: Optional[float] = None, *, retain_output: bool, + shell: Optional[Sequence[str]] = None, ) -> CommandHandle[Optional[str]]: ... def run( @@ -397,6 +408,7 @@ 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: @@ -417,6 +429,7 @@ def run( timeout, request_timeout, retain_output, + shell, ) return ( @@ -438,14 +451,16 @@ def _start( timeout: Optional[float], request_timeout: Optional[float], retain_output: bool, + shell: Optional[Sequence[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..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 @@ -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,32 @@ 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=[]) + + +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 3186e84fc6..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 @@ -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,32 @@ 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=[]) + + +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]