diff --git a/sdk/js/__test__/shell-exec-env.test.ts b/sdk/js/__test__/shell-exec-env.test.ts new file mode 100644 index 0000000..1333b10 --- /dev/null +++ b/sdk/js/__test__/shell-exec-env.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for the env parameter in ShellExecRequest. + * Validates that the env field is correctly typed and included in the request interface. + */ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { ShellExecRequest } from '../src/api/resources/shell/client/requests/ShellExecRequest.js'; + +const SRC_DIR = path.join(__dirname, '../src'); + +describe('ShellExecRequest env parameter', () => { + describe('TypeScript interface', () => { + it('should accept env as Record', () => { + const request: ShellExecRequest = { + command: 'echo $MY_VAR', + env: { + MY_VAR: 'hello', + PATH: '/usr/bin:/bin', + }, + }; + + expect(request.env).toEqual({ + MY_VAR: 'hello', + PATH: '/usr/bin:/bin', + }); + expect(request.command).toBe('echo $MY_VAR'); + }); + + it('should accept env as null', () => { + const request: ShellExecRequest = { + command: 'ls', + env: null, + }; + + expect(request.env).toBeNull(); + }); + + it('should accept env as undefined (omitted)', () => { + const request: ShellExecRequest = { + command: 'ls', + }; + + expect(request.env).toBeUndefined(); + }); + + it('should accept empty env object', () => { + const request: ShellExecRequest = { + command: 'ls', + env: {}, + }; + + expect(request.env).toEqual({}); + }); + + it('should work with all other parameters combined', () => { + const request: ShellExecRequest = { + command: 'echo $DB_HOST', + id: 'session-1', + exec_dir: '/tmp', + async_mode: false, + timeout: 30, + strict: true, + no_change_timeout: 10, + hard_timeout: 60, + preserve_symlinks: false, + truncate: true, + env: { + DB_HOST: 'localhost', + DB_PORT: '5432', + NODE_ENV: 'test', + }, + }; + + expect(request.env).toEqual({ + DB_HOST: 'localhost', + DB_PORT: '5432', + NODE_ENV: 'test', + }); + expect(request.command).toBe('echo $DB_HOST'); + expect(request.id).toBe('session-1'); + }); + + it('should handle env with special characters in values', () => { + const request: ShellExecRequest = { + command: 'printenv', + env: { + SPECIAL: 'value with spaces', + QUOTED: '"double quoted"', + EQUALS: 'key=value', + EMPTY: '', + UNICODE: 'unicode-\u00e9\u00e8\u00ea', + }, + }; + + expect(request.env?.['SPECIAL']).toBe('value with spaces'); + expect(request.env?.['QUOTED']).toBe('"double quoted"'); + expect(request.env?.['EQUALS']).toBe('key=value'); + expect(request.env?.['EMPTY']).toBe(''); + expect(request.env?.['UNICODE']).toContain('unicode-'); + }); + }); + + describe('Source code verification', () => { + it('ShellExecRequest.ts should contain env field definition', () => { + const filePath = path.join( + SRC_DIR, + 'api/resources/shell/client/requests/ShellExecRequest.ts' + ); + const content = fs.readFileSync(filePath, 'utf-8'); + + expect(content).toContain('env?:'); + expect(content).toContain('Record'); + expect(content).toContain('Environment variables'); + }); + + it('Shell Client.ts should send request body including env', () => { + const filePath = path.join( + SRC_DIR, + 'api/resources/shell/client/Client.ts' + ); + const content = fs.readFileSync(filePath, 'utf-8'); + + // The JS client sends the entire request object as body, + // so env will be included automatically + expect(content).toContain('body: request'); + }); + }); + + describe('JSON serialization', () => { + it('should serialize env correctly in request body', () => { + const request: ShellExecRequest = { + command: 'env', + env: { + FOO: 'bar', + BAZ: 'qux', + }, + }; + + const json = JSON.stringify(request); + const parsed = JSON.parse(json); + + expect(parsed.env).toEqual({ FOO: 'bar', BAZ: 'qux' }); + expect(parsed.command).toBe('env'); + }); + + it('should omit env from JSON when undefined', () => { + const request: ShellExecRequest = { + command: 'ls', + }; + + const json = JSON.stringify(request); + const parsed = JSON.parse(json); + + expect(parsed).not.toHaveProperty('env'); + }); + + it('should include null env in JSON', () => { + const request: ShellExecRequest = { + command: 'ls', + env: null, + }; + + const json = JSON.stringify(request); + const parsed = JSON.parse(json); + + expect(parsed.env).toBeNull(); + }); + }); +}); diff --git a/sdk/js/src/api/resources/shell/client/requests/ShellExecRequest.ts b/sdk/js/src/api/resources/shell/client/requests/ShellExecRequest.ts index a03311f..99e1b92 100644 --- a/sdk/js/src/api/resources/shell/client/requests/ShellExecRequest.ts +++ b/sdk/js/src/api/resources/shell/client/requests/ShellExecRequest.ts @@ -27,4 +27,6 @@ export interface ShellExecRequest { preserve_symlinks?: boolean; /** If True, truncate output when it exceeds 30000 characters (default: True) */ truncate?: boolean; + /** Environment variables to set for the command execution. These will be merged with the existing process environment, with provided values taking precedence. */ + env?: Record | null; } diff --git a/sdk/python/agent_sandbox/shell/client.py b/sdk/python/agent_sandbox/shell/client.py index 63d51d1..c509f78 100644 --- a/sdk/python/agent_sandbox/shell/client.py +++ b/sdk/python/agent_sandbox/shell/client.py @@ -47,6 +47,7 @@ def exec_command( hard_timeout: typing.Optional[float] = OMIT, preserve_symlinks: typing.Optional[bool] = OMIT, truncate: typing.Optional[bool] = OMIT, + env: typing.Optional[typing.Dict[str, str]] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> ResponseShellCommandResult: """ @@ -85,6 +86,10 @@ def exec_command( truncate : typing.Optional[bool] If True, truncate output when it exceeds 30000 characters (default: True) + env : typing.Optional[typing.Dict[str, str]] + Environment variables to set for the command execution. These will be merged with + the existing process environment, with provided values taking precedence. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -115,6 +120,7 @@ def exec_command( hard_timeout=hard_timeout, preserve_symlinks=preserve_symlinks, truncate=truncate, + env=env, request_options=request_options, ) return _response.data @@ -507,6 +513,7 @@ async def exec_command( hard_timeout: typing.Optional[float] = OMIT, preserve_symlinks: typing.Optional[bool] = OMIT, truncate: typing.Optional[bool] = OMIT, + env: typing.Optional[typing.Dict[str, str]] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> ResponseShellCommandResult: """ @@ -545,6 +552,10 @@ async def exec_command( truncate : typing.Optional[bool] If True, truncate output when it exceeds 30000 characters (default: True) + env : typing.Optional[typing.Dict[str, str]] + Environment variables to set for the command execution. These will be merged with + the existing process environment, with provided values taking precedence. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -583,6 +594,7 @@ async def main() -> None: hard_timeout=hard_timeout, preserve_symlinks=preserve_symlinks, truncate=truncate, + env=env, request_options=request_options, ) return _response.data diff --git a/sdk/python/agent_sandbox/shell/raw_client.py b/sdk/python/agent_sandbox/shell/raw_client.py index 0f089ac..9373597 100644 --- a/sdk/python/agent_sandbox/shell/raw_client.py +++ b/sdk/python/agent_sandbox/shell/raw_client.py @@ -42,6 +42,7 @@ def exec_command( hard_timeout: typing.Optional[float] = OMIT, preserve_symlinks: typing.Optional[bool] = OMIT, truncate: typing.Optional[bool] = OMIT, + env: typing.Optional[typing.Dict[str, str]] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[ResponseShellCommandResult]: """ @@ -80,6 +81,10 @@ def exec_command( truncate : typing.Optional[bool] If True, truncate output when it exceeds 30000 characters (default: True) + env : typing.Optional[typing.Dict[str, str]] + Environment variables to set for the command execution. These will be merged with + the existing process environment, with provided values taking precedence. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -102,6 +107,7 @@ def exec_command( "hard_timeout": hard_timeout, "preserve_symlinks": preserve_symlinks, "truncate": truncate, + "env": env, }, headers={ "content-type": "application/json", @@ -699,6 +705,7 @@ async def exec_command( hard_timeout: typing.Optional[float] = OMIT, preserve_symlinks: typing.Optional[bool] = OMIT, truncate: typing.Optional[bool] = OMIT, + env: typing.Optional[typing.Dict[str, str]] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[ResponseShellCommandResult]: """ @@ -737,6 +744,10 @@ async def exec_command( truncate : typing.Optional[bool] If True, truncate output when it exceeds 30000 characters (default: True) + env : typing.Optional[typing.Dict[str, str]] + Environment variables to set for the command execution. These will be merged with + the existing process environment, with provided values taking precedence. + request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -759,6 +770,7 @@ async def exec_command( "hard_timeout": hard_timeout, "preserve_symlinks": preserve_symlinks, "truncate": truncate, + "env": env, }, headers={ "content-type": "application/json", diff --git a/sdk/python/tests/__init__.py b/sdk/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/python/tests/test_shell_exec_env.py b/sdk/python/tests/test_shell_exec_env.py new file mode 100644 index 0000000..e484569 --- /dev/null +++ b/sdk/python/tests/test_shell_exec_env.py @@ -0,0 +1,260 @@ +""" +Tests for the env parameter in /v1/shell/exec request. + +Validates that the env field is correctly accepted by both sync and async +ShellClient and RawShellClient, and is properly serialized in the request body. +""" + +import inspect +import typing +import unittest +from unittest.mock import MagicMock, AsyncMock, patch + +from agent_sandbox.shell.client import ShellClient, AsyncShellClient +from agent_sandbox.shell.raw_client import RawShellClient, AsyncRawShellClient + + +class TestShellExecEnvParameterSignature(unittest.TestCase): + """Verify that the env parameter exists in all exec_command signatures.""" + + def test_raw_shell_client_has_env_param(self): + sig = inspect.signature(RawShellClient.exec_command) + self.assertIn("env", sig.parameters) + param = sig.parameters["env"] + self.assertEqual(param.kind, inspect.Parameter.KEYWORD_ONLY) + + def test_async_raw_shell_client_has_env_param(self): + sig = inspect.signature(AsyncRawShellClient.exec_command) + self.assertIn("env", sig.parameters) + param = sig.parameters["env"] + self.assertEqual(param.kind, inspect.Parameter.KEYWORD_ONLY) + + def test_shell_client_has_env_param(self): + sig = inspect.signature(ShellClient.exec_command) + self.assertIn("env", sig.parameters) + param = sig.parameters["env"] + self.assertEqual(param.kind, inspect.Parameter.KEYWORD_ONLY) + + def test_async_shell_client_has_env_param(self): + sig = inspect.signature(AsyncShellClient.exec_command) + self.assertIn("env", sig.parameters) + param = sig.parameters["env"] + self.assertEqual(param.kind, inspect.Parameter.KEYWORD_ONLY) + + +class TestShellExecEnvParameterSerialization(unittest.TestCase): + """Verify that the env parameter is included in the request JSON body.""" + + def _make_sync_client(self): + """Create a RawShellClient with mocked httpx client.""" + wrapper = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "data": { + "id": "test-session", + "status": "completed", + "exit_code": 0, + "output": "test output", + }, + } + mock_response.headers = {} + wrapper.httpx_client.request.return_value = mock_response + return RawShellClient(client_wrapper=wrapper), wrapper + + def test_env_included_in_request_body(self): + """Env dict should appear in the JSON body sent to the API.""" + client, wrapper = self._make_sync_client() + env_vars = {"MY_VAR": "hello", "PATH": "/usr/bin"} + + try: + client.exec_command(command="echo $MY_VAR", env=env_vars) + except Exception: + pass # We only care about what was sent + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + self.assertIn("env", json_body) + self.assertEqual(json_body["env"], {"MY_VAR": "hello", "PATH": "/usr/bin"}) + + def test_env_none_included_in_request_body(self): + """When env=None, it should be sent as None in the body.""" + client, wrapper = self._make_sync_client() + + try: + client.exec_command(command="ls", env=None) + except Exception: + pass + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + self.assertIn("env", json_body) + self.assertIsNone(json_body["env"]) + + def test_env_omitted_when_not_provided(self): + """When env is not provided, it should use the OMIT sentinel.""" + client, wrapper = self._make_sync_client() + + try: + client.exec_command(command="ls") + except Exception: + pass + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + # The OMIT sentinel is used; the httpx client wrapper will strip it + self.assertIn("env", json_body) + + def test_env_empty_dict_included(self): + """An empty env dict should be sent as-is.""" + client, wrapper = self._make_sync_client() + + try: + client.exec_command(command="ls", env={}) + except Exception: + pass + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + self.assertIn("env", json_body) + self.assertEqual(json_body["env"], {}) + + def test_env_with_special_values(self): + """Env values with special characters should be preserved.""" + client, wrapper = self._make_sync_client() + env_vars = { + "SPACES": "value with spaces", + "EQUALS": "key=value", + "EMPTY": "", + "QUOTES": '"quoted"', + "NEWLINE": "line1\nline2", + } + + try: + client.exec_command(command="printenv", env=env_vars) + except Exception: + pass + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + self.assertEqual(json_body["env"]["SPACES"], "value with spaces") + self.assertEqual(json_body["env"]["EQUALS"], "key=value") + self.assertEqual(json_body["env"]["EMPTY"], "") + self.assertEqual(json_body["env"]["QUOTES"], '"quoted"') + self.assertEqual(json_body["env"]["NEWLINE"], "line1\nline2") + + def test_env_coexists_with_other_params(self): + """Env should work alongside all other parameters.""" + client, wrapper = self._make_sync_client() + + try: + client.exec_command( + command="echo $DB_HOST", + id="session-1", + exec_dir="/tmp", + async_mode=False, + timeout=30.0, + strict=True, + no_change_timeout=10, + hard_timeout=60.0, + preserve_symlinks=False, + truncate=True, + env={"DB_HOST": "localhost", "DB_PORT": "5432"}, + ) + except Exception: + pass + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + self.assertEqual(json_body["command"], "echo $DB_HOST") + self.assertEqual(json_body["id"], "session-1") + self.assertEqual(json_body["exec_dir"], "/tmp") + self.assertEqual(json_body["env"], {"DB_HOST": "localhost", "DB_PORT": "5432"}) + + +class TestShellClientEnvDelegation(unittest.TestCase): + """Verify that ShellClient properly delegates env to RawShellClient.""" + + def test_shell_client_passes_env_to_raw_client(self): + """ShellClient.exec_command should forward env to RawShellClient.""" + wrapper = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "data": { + "id": "test", + "status": "completed", + "exit_code": 0, + "output": "", + }, + } + mock_response.headers = {} + wrapper.httpx_client.request.return_value = mock_response + + client = ShellClient(client_wrapper=wrapper) + env_vars = {"FOO": "bar"} + + try: + client.exec_command(command="echo $FOO", env=env_vars) + except Exception: + pass + + call_args = wrapper.httpx_client.request.call_args + json_body = call_args.kwargs.get("json") or call_args[1].get("json") + + self.assertEqual(json_body["env"], {"FOO": "bar"}) + + +class TestOpenAPISpecEnvField(unittest.TestCase): + """Verify the OpenAPI spec includes the env field.""" + + def test_openapi_spec_has_env_field(self): + import json + import os + + spec_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "website", + "docs", + "public", + "v1", + "openapi.json", + ) + spec_path = os.path.normpath(spec_path) + + if not os.path.exists(spec_path): + self.skipTest(f"OpenAPI spec not found at {spec_path}") + + with open(spec_path, "r") as f: + spec = json.load(f) + + shell_exec_schema = spec["components"]["schemas"]["ShellExecRequest"] + self.assertIn("env", shell_exec_schema["properties"]) + + env_prop = shell_exec_schema["properties"]["env"] + # Should be nullable (anyOf with object and null) + self.assertIn("anyOf", env_prop) + + any_of_types = [item.get("type") for item in env_prop["anyOf"]] + self.assertIn("object", any_of_types) + self.assertIn("null", any_of_types) + + # The object type should have additionalProperties: { type: string } + obj_schema = next(s for s in env_prop["anyOf"] if s.get("type") == "object") + self.assertIn("additionalProperties", obj_schema) + self.assertEqual(obj_schema["additionalProperties"]["type"], "string") + + +if __name__ == "__main__": + unittest.main() diff --git a/website/docs/public/v1/openapi.json b/website/docs/public/v1/openapi.json index 017916d..4135b8c 100644 --- a/website/docs/public/v1/openapi.json +++ b/website/docs/public/v1/openapi.json @@ -8476,6 +8476,17 @@ "title": "Truncate", "description": "If True, truncate output when it exceeds 30000 characters (default: True)", "default": true + }, + "env": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { "type": "string" } + }, + { "type": "null" } + ], + "title": "Env", + "description": "Environment variables to set for the command execution. These will be merged with the existing process environment, with provided values taking precedence." } }, "type": "object",