From 534f4777c0ca3db037b0ac47e37a19ed620a31e6 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 15 Oct 2026 15:05:03 -0700 Subject: [PATCH 1/5] feat(run_code): give a script on an env's host the env's endpoints, as that host reaches them A run_code script on an env's host had no way to find the env: scripts assumed the gateway at 127.0.0.1:18765, where VM providers publish it. The local provider maps that port into 20000-30000, since deployments share one host, so those scripts got connection refused. Its input now carries env = {id, gateway_url, mcp_url}, each URL under one of the sandbox's tunnels rewritten to loopback at the port the tunnel publishes (host_port), since a host can't always call its own public URL. On VM providers that is 127.0.0.1:18765, as before; on the local provider, the mapped port. Scripts on a sandbox or in an agent get no env. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/run_code.py | 45 +++++++-- .../cli/run_bundle_built_envs_test.py | 40 +++++++- tst/unit/task_step/test_run_code.py | 96 +++++++++++++++++-- 3 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/agent_env/task_step/task_steps/run_code.py b/src/agent_env/task_step/task_steps/run_code.py index 32bd4bef..b3a51eb8 100644 --- a/src/agent_env/task_step/task_steps/run_code.py +++ b/src/agent_env/task_step/task_steps/run_code.py @@ -2,6 +2,10 @@ The script defines ``run(input) -> JSON``, where ``input = {"args", "results"}``. The result is stored at ``context.metadata["script_results"][result_id]``. +A script on an env's host also gets ``input["env"] = {"id", "gateway_url", "mcp_url"}``: +the env's endpoints as that host reaches them, on loopback at the ports they're published +on, since a host can't always call its own public URL. ``gateway_url`` is None for an env +with no gateway. ``script_artifact_id`` is either a single-file ``FileArtifact`` or a ``FileArtifactUniverse`` whose ``.py`` members are staged side by side so the entry @@ -28,7 +32,7 @@ from agent_env.artifact import Artifact, FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse -from agent_env.env.env import require_sandbox +from agent_env.env.env import DeployedGatewayEnv, require_sandbox from agent_env.task_step.context import TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -166,7 +170,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: target = await self._resolve_target(context) run_dir = f"{_RUN_DIR}/{self.id}" - await self._stage(target, run_dir, files, self._build_input(context)) + await self._stage(target, run_dir, files, self._build_input(context, target)) await self._run(target, run_dir, script_file) result = await self._read_output(target, run_dir) @@ -289,12 +293,15 @@ def _load_script(self) -> tuple[str, dict[str, bytes]]: ) return (script_file, files) - def _build_input(self, context: TaskStepContext) -> dict: - """Build the {args, results} input passed to the script.""" - return { + def _build_input(self, context: TaskStepContext, target: _ExecTarget) -> dict: + """Build the {args, results} input passed to the script, and its env's endpoints on an env's host.""" + script_input = { "args": self.args, "results": context.metadata.get("script_results", {}), } + if target.env is not None: + script_input["env"] = target.env + return script_input def _target_kind(self) -> str: if self.env_id is not None: @@ -386,7 +393,7 @@ async def _host_target(self, context: TaskStepContext) -> _ExecTarget: else get_env_sandbox_provider() ) sandbox = await provider.get_sandbox(deployed.sandbox_id) - return _HostTarget(sandbox) + return _HostTarget(sandbox, env=_env_on_host(deployed, sandbox)) async def _sandbox_target(self, context: TaskStepContext) -> _ExecTarget: from agent_env.providers.sandbox_providers.sandbox import VmSandbox @@ -480,7 +487,31 @@ def _is_safe_member(name: str) -> bool: return ".." not in PurePosixPath(name).parts +def _env_on_host(deployed: Any, sandbox: Any) -> dict: + """The env's id and endpoints as a script on its host reaches them.""" + gateway_url = deployed.gateway_url if isinstance(deployed, DeployedGatewayEnv) else None + return { + "id": deployed.env_id, + "gateway_url": _on_host(gateway_url, sandbox), + "mcp_url": _on_host(deployed.mcp_url, sandbox), + } + + +def _on_host(url: Optional[str], sandbox: Any) -> Optional[str]: + """``url`` as ``sandbox``'s own host reaches it: one under a tunnel goes to loopback at the port it publishes.""" + if url is None: + return None + # The longest first, so a tunnel at a path under another's takes its own URLs. + for port, tunnel in sorted(sandbox.tunnel_urls.items(), key=lambda item: -len(item[1])): + base = tunnel.rstrip("/") + if url == base or url.startswith(f"{base}/"): + return f"http://127.0.0.1:{sandbox.host_port(port)}{url[len(base):]}" + return url + + class _ExecTarget(Protocol): + env: Optional[dict] # the env's endpoints, for a script on an env's host + async def reset_dir(self, path: str) -> None: ... async def write_file(self, path: str, content: str) -> None: ... async def run(self, *command: str) -> tuple[int, str, str]: ... @@ -491,6 +522,7 @@ class _HostTarget: """Runs the script on a VM host (an env's or a deployed sandbox's; no agent).""" sandbox: Any + env: Optional[dict] = None async def reset_dir(self, path: str) -> None: await self.sandbox.exec_script( @@ -511,6 +543,7 @@ class _AgentTarget: sandbox: Any prefix: tuple[str, ...] + env: Optional[dict] = None async def reset_dir(self, path: str) -> None: await self.run("rm", "-rf", path) diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index 26048db3..5eb6858b 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -8,7 +8,8 @@ of bundle and store servers, and a store universe shared across servers seeds the one server it's loaded into. The echo-mcp example agent-env ships runs by name, and a rerun writes nothing, and a task held under bare ids, as a registry holds it, imports as a bundle that runs through the gateway; an output a bundle's task wrote is written under the folder -that imports the task. Needs a Docker daemon and the local registry.""" +that imports the task. A script run on an env's host reaches the env's gateway and MCP endpoint at the addresses its +input gives. Needs a Docker daemon and the local registry.""" import hashlib import json @@ -574,6 +575,43 @@ def _store_echo_server() -> None: MCPServerEnv.put(id="echo-server", docker_image_artifact=image, environment_name="echo") +# Fetches the env's card from the gateway and opens an MCP session at the env's endpoint, both at the addresses its +# input gives: on the local provider, the host ports the sandbox mapped, not the gateway's own port. +_REACH = """\ +import json, urllib.parse, urllib.request + +def run(inp): + env = inp["env"] + gateway = urllib.parse.urlsplit(env["gateway_url"]) + assert (gateway.hostname, gateway.port != 18765) == ("127.0.0.1", True), env + with urllib.request.urlopen(env["gateway_url"] + "/.well-known/agent-env.json", timeout=30) as response: + card = json.load(response) + initialize = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reach", "version": "1"}}} + request = urllib.request.Request(env["mcp_url"], data=json.dumps(initialize).encode(), headers={ + "Content-Type": "application/json", "Accept": "application/json, text/event-stream"}) + with urllib.request.urlopen(request, timeout=30) as response: + assert response.status == 200, response.status + return {"card": card["name"]} +""" + + +def test_a_script_on_an_envs_host_reaches_its_gateway_and_mcp_endpoint_at_the_addresses_its_input_gives(state): + root = state / "reach" + shutil.copytree(ECHO / "envs", root / "envs") + (root / "artifacts" / "reach").mkdir(parents=True) + (root / "artifacts" / "reach" / "run.py").write_text(_REACH) + (root / "tasks").mkdir() + (root / "tasks" / "reach.json").write_text(json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": "echo", "sandbox_type": "local"}, + {"id": "reach", "type": "run_code", "env_id": "echo", "script_artifact_id": "reach", "depends_on": ["env"]}])) + + result = _run(root) + + assert result.exit_code == 0, result.output + assert "tasks/reach.json v1: unscored" in result.output, result.output + + def test_a_stored_mcp_task_imports_as_a_bundle_that_runs_through_the_gateway(state): _store_echo_server() FileArtifact.put_bytes("echo-check", description="calls echo", filename="verify.py", diff --git a/tst/unit/task_step/test_run_code.py b/tst/unit/task_step/test_run_code.py index aa7de17c..d8c2adad 100644 --- a/tst/unit/task_step/test_run_code.py +++ b/tst/unit/task_step/test_run_code.py @@ -19,7 +19,8 @@ from agent_env.artifact import FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse -from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, EnvNeedsSandbox +from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, DeployedSandboxEnv, EnvNeedsSandbox +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox from agent_env.providers.sandbox_providers.sandbox import VmSandbox from agent_env.task_step.context import DeployedAgent, DeployedSandbox, TaskStepContext from agent_env.task_step.task_steps import run_code as _run_code_module @@ -102,6 +103,16 @@ def _get_member(artifact_id, version=None): return _file_artifact(artifact_id.removeprefix("fa-"), _BODIES[artifact_id.removeprefix("fa-")]) +def _vm_sandbox_mock(): + """A VM sandbox at the boundary; tunnels and host ports are plain attributes of a real one.""" + sandbox = AsyncMock() + sandbox.mode = "vm" + sandbox.container_name = "agent-api" + sandbox.tunnel_urls = {} + sandbox.host_port = MagicMock(side_effect=lambda port: port) + return sandbox + + @contextmanager def _sandbox(exec_results, *, artifact="single", agent_mode=True): """Mock only the external boundary: the Sandbox (VMs/docker), the sandbox @@ -112,9 +123,7 @@ def _sandbox(exec_results, *, artifact="single", agent_mode=True): `artifact` is "single", "wrong-type", or a prebuilt artifact. """ - sandbox = AsyncMock() - sandbox.mode = "vm" - sandbox.container_name = "agent-api" + sandbox = _vm_sandbox_mock() # Before the script runs: reset_dir's `rm -rf` + `mkdir -p` (agent mode only; # host mode clears via exec_script). prefix = [(0, "", ""), (0, "", "")] if agent_mode else [] @@ -248,6 +257,81 @@ async def test_host_target_on_an_env_outside_our_sandboxes_says_it_needs_one(): await _step(env_id="env-x", result_id="filter")._host_target(ctx) +class _Tunnels: + """A sandbox's tunnels and host ports, as a provider reports them.""" + + def __init__(self, tunnel_urls, port_map=None): + self.tunnel_urls = tunnel_urls + self._port_map = port_map or {} + + def host_port(self, port): + return self._port_map.get(port, port) + + +@pytest.mark.parametrize("tunnels,url,expected", [ + ({18765: "https://abc.modal.host"}, "https://abc.modal.host", "http://127.0.0.1:18765"), + ({18765: "https://abc.modal.host"}, "https://abc.modal.host/mcp", "http://127.0.0.1:18765/mcp"), + # a proxy's path prefix, with another tunnel at a path above it + ({80: "https://proxy.example/sb/vm-1", 18765: "https://proxy.example/sb/vm-1/port/18765/"}, + "https://proxy.example/sb/vm-1/port/18765/mcp", "http://127.0.0.1:18765/mcp"), + ({80: "https://proxy.example/sb/vm-1", 18765: "https://proxy.example/sb/vm-1/port/18765/"}, + "https://proxy.example/sb/vm-1/web/", "http://127.0.0.1:80/web/"), + ({18765: "https://abc.modal.host"}, "https://elsewhere.example/mcp", "https://elsewhere.example/mcp"), + ({18765: "http://127.0.0.1:2000"}, "http://127.0.0.1:20001/mcp", "http://127.0.0.1:20001/mcp"), + ({18765: "https://abc.modal.host"}, None, None), +]) +def test_a_url_under_its_hosts_tunnel_is_reached_on_loopback_at_the_published_port(tunnels, url, expected): + assert _run_code_module._on_host(url, _Tunnels(tunnels)) == expected + + +def test_a_local_sandbox_reaches_its_gateway_at_the_host_port_it_mapped(tmp_path): + sandbox = LocalSandbox(work_dir=tmp_path, port_map={18765: 23456}) + gateway = sandbox.tunnel_urls[18765] + + assert _run_code_module._on_host(f"{gateway}/mcp", sandbox) == "http://127.0.0.1:23456/mcp" + + +def test_an_env_with_no_gateway_gives_its_mcp_url_alone(): + deployed = DeployedSandboxEnv(env_id="srv", env_version=1, sandbox_id="sb", mcp_url="https://abc.modal.host/mcp") + + env = _run_code_module._env_on_host(deployed, _Tunnels({8000: "https://abc.modal.host"})) + + assert env == {"id": "srv", "gateway_url": None, "mcp_url": "http://127.0.0.1:8000/mcp"} + + +def _written_input(sandbox): + """The input.json the step staged, through whichever writer its target uses.""" + for call in sandbox.write_host_file.await_args_list: + if str(call.args[1]).endswith("/input.json"): + return json.loads(call.args[0].decode()) + for call in sandbox.write_file_from_text.await_args_list: + if str(call.args[1]).endswith("/input.json"): + return json.loads(call.args[0]) + raise AssertionError("no input.json was staged") + + +@pytest.mark.asyncio +async def test_a_script_on_an_envs_host_gets_the_envs_endpoints_as_that_host_reaches_them(): + ctx = _make_context(with_agent=False) + ctx.deployed_envs[0].gateway_url = "https://abc.modal.host" + ctx.deployed_envs[0].mcp_url = "https://abc.modal.host/mcp" + with _sandbox([(0, "", ""), (0, "2", ""), (0, "{}", "")], agent_mode=False) as sandbox: + sandbox.tunnel_urls = {18765: "https://abc.modal.host"} + await _step(env_id="env-x").execute(ctx) + + assert _written_input(sandbox)["env"] == { + "id": "env-x", "gateway_url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp", + } + + +@pytest.mark.asyncio +async def test_a_script_in_an_agent_gets_no_env(): + with _sandbox([(0, "", ""), (0, "2", ""), (0, "{}", "")]) as sandbox: + await _step().execute(_make_context()) + + assert set(_written_input(sandbox)) == {"args", "results"} + + @pytest.mark.asyncio async def test_host_target_honors_custom_env_sandbox_type(): # custom sandbox_type must resolve via build_sandbox_provider, not the default provider @@ -255,9 +339,7 @@ async def test_host_target_honors_custom_env_sandbox_type(): ctx = _make_context(with_agent=False) ctx.deployed_envs[0].sandbox_type = "arp" - sandbox = AsyncMock() - sandbox.mode = "vm" - sandbox.container_name = "agent-api" + sandbox = _vm_sandbox_mock() sandbox.exec_with_output = AsyncMock( # leading entry is the `env` read execute() does for redaction # host mode clears via exec_script, so exec_with_output sees only: From c34e5bd8d23aa8c6ebf8c5e3195e612b187de051 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 15 Oct 2026 15:30:40 -0700 Subject: [PATCH 2/5] fix(run_code): stage each run's files in a dir of its sandbox's own, so runs on one machine can't clear each other's run_code staged into /tmp/agent_env_run_code/ on the host. Local sandboxes share this machine's /tmp, so two runs of a step with the same id, such as the same task run twice at once, staged into one dir: a later run cleared the earlier one's files (runner.py missing) or replaced its input and result, so one script read another run's env. Chaos found it with four runs of one bundle at once, on the old release too. A host target now stages in sandbox.scoped_name(_RUN_DIR)/, which on the local provider ends in the sandbox's id, so its teardown removes it; on other providers scoped_name keeps the name, so nothing changes there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/run_code.py | 10 ++++- tst/unit/task_step/test_run_code.py | 40 +++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/agent_env/task_step/task_steps/run_code.py b/src/agent_env/task_step/task_steps/run_code.py index b3a51eb8..17817d54 100644 --- a/src/agent_env/task_step/task_steps/run_code.py +++ b/src/agent_env/task_step/task_steps/run_code.py @@ -168,7 +168,7 @@ def preflight(self) -> list[str]: async def execute(self, context: TaskStepContext) -> TaskStepContext: script_file, files = await asyncio.to_thread(self._load_script) target = await self._resolve_target(context) - run_dir = f"{_RUN_DIR}/{self.id}" + run_dir = target.run_dir(self.id) await self._stage(target, run_dir, files, self._build_input(context, target)) await self._run(target, run_dir, script_file) @@ -512,6 +512,7 @@ def _on_host(url: Optional[str], sandbox: Any) -> Optional[str]: class _ExecTarget(Protocol): env: Optional[dict] # the env's endpoints, for a script on an env's host + def run_dir(self, step_id: str) -> str: ... async def reset_dir(self, path: str) -> None: ... async def write_file(self, path: str, content: str) -> None: ... async def run(self, *command: str) -> tuple[int, str, str]: ... @@ -524,6 +525,10 @@ class _HostTarget: sandbox: Any env: Optional[dict] = None + def run_dir(self, step_id: str) -> str: + # The sandbox's own: runs on the local provider share this machine's /tmp, and teardown removes it there. + return f"{self.sandbox.scoped_name(_RUN_DIR)}/{step_id}" + async def reset_dir(self, path: str) -> None: await self.sandbox.exec_script( f"rm -rf {shlex.quote(path)} && mkdir -p {shlex.quote(path)}" @@ -545,6 +550,9 @@ class _AgentTarget: prefix: tuple[str, ...] env: Optional[dict] = None + def run_dir(self, step_id: str) -> str: + return f"{_RUN_DIR}/{step_id}" + async def reset_dir(self, path: str) -> None: await self.run("rm", "-rf", path) await self.run("mkdir", "-p", path) diff --git a/tst/unit/task_step/test_run_code.py b/tst/unit/task_step/test_run_code.py index d8c2adad..5d21b541 100644 --- a/tst/unit/task_step/test_run_code.py +++ b/tst/unit/task_step/test_run_code.py @@ -110,6 +110,7 @@ def _vm_sandbox_mock(): sandbox.container_name = "agent-api" sandbox.tunnel_urls = {} sandbox.host_port = MagicMock(side_effect=lambda port: port) + sandbox.scoped_name = MagicMock(side_effect=lambda name: name) return sandbox @@ -332,6 +333,34 @@ async def test_a_script_in_an_agent_gets_no_env(): assert set(_written_input(sandbox)) == {"args", "results"} +@pytest.mark.asyncio +async def test_runs_of_one_step_on_this_machine_stage_into_dirs_of_their_own(tmp_path): + """Local sandboxes share this machine's /tmp: a run of the same step on another must not clear this one's files.""" + sandboxes = {} + for sid in ("local-aaaa1111", "local-bbbb2222"): + (tmp_path / sid).mkdir() + sandboxes[sid] = LocalSandbox(sandbox_id=sid, work_dir=tmp_path / sid) + provider = MagicMock() + provider.get_sandbox = AsyncMock(side_effect=lambda sid: sandboxes[sid]) + artifact = _file_artifact(body=b"def run(inp):\n return inp['args']\n") + with patch( + "agent_env.providers.sandbox_providers.sandbox_provider.build_sandbox_provider", return_value=provider, + ), patch("agent_env.artifact.Artifact.get", return_value=artifact), patch.object( + FileArtifact, "load", _load_body + ), patch.object(_run_code_module, "_RUN_DIR", str(tmp_path / "run")): + for sid in sandboxes: + ctx = _make_context(with_agent=False) + ctx.deployed_sandboxes = [ + DeployedSandbox(sandbox_name="box", sandbox_id=sid, sandbox_mode="vm", sandbox_type="local") + ] + await _step(sandbox_name="box", args={"who": sid}).execute(ctx) + assert ctx.metadata["script_results"]["run-code-1"] == {"who": sid} + + for sid in sandboxes: + staged = json.loads((tmp_path / f"run-{sid}" / "run-code-1" / "input.json").read_text()) + assert staged["args"] == {"who": sid} + + @pytest.mark.asyncio async def test_host_target_honors_custom_env_sandbox_type(): # custom sandbox_type must resolve via build_sandbox_provider, not the default provider @@ -370,8 +399,7 @@ def _sandbox_context(*, sandbox_type=None): def _host_sandbox(): - sandbox = AsyncMock() - sandbox.mode = "vm" + sandbox = _vm_sandbox_mock() sandbox.exec_with_output = AsyncMock( side_effect=[(0, "", ""), (0, "13", ""), (0, '{"keep": true}', "")] ) @@ -527,9 +555,7 @@ async def test_agent_target_honors_the_agents_sandbox_type(): ctx = _make_context() ctx.deployed_agents[0].sandbox_type = "modal" - sandbox = AsyncMock() - sandbox.mode = "vm" - sandbox.container_name = "agent-api" + sandbox = _vm_sandbox_mock() sandbox.exec_with_output = AsyncMock( # the `env` read execute() does for redaction, the clear, the script run, `wc -c`, then `cat` side_effect=[(0, "", ""), (0, "", ""), (0, "", ""), (0, "13", ""), (0, '{"keep": true}', "")] @@ -571,9 +597,7 @@ async def test_host_installed_agent_on_a_vm_runs_on_the_host(): ctx.deployed_agents[0].sandbox_type = "modal_vm" ctx.deployed_agents[0].on_host = True - sandbox = AsyncMock() - sandbox.mode = "vm" - sandbox.container_name = "agent-api" + sandbox = _vm_sandbox_mock() sandbox.exec_with_output = AsyncMock( side_effect=[(0, "", ""), (0, "13", ""), (0, '{"keep": true}', "")] ) From 2dc4b9cb485e81c45e40d4a0c10bbaf55696b20c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 15 Oct 2026 17:18:34 -0700 Subject: [PATCH 3/5] refactor(run_code): decide a script's env from its env_id, where its input is built The env's endpoints rode on the target: an env field on both target classes and the protocol, None for every agent target. The rule is simpler than that, so _build_input now states it: a step with env_id gets input["env"], built from the env's record and the target's sandbox. The record's lookup moves to _deployed_env, which _host_target shares, and the protocol declares the sandbox both targets carry instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../task_step/task_steps/run_code.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/agent_env/task_step/task_steps/run_code.py b/src/agent_env/task_step/task_steps/run_code.py index 17817d54..03fb1d5c 100644 --- a/src/agent_env/task_step/task_steps/run_code.py +++ b/src/agent_env/task_step/task_steps/run_code.py @@ -32,7 +32,7 @@ from agent_env.artifact import Artifact, FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse -from agent_env.env.env import DeployedGatewayEnv, require_sandbox +from agent_env.env.env import DeployedGatewayEnv, DeployedSandboxEnv, require_sandbox from agent_env.task_step.context import TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -299,8 +299,8 @@ def _build_input(self, context: TaskStepContext, target: _ExecTarget) -> dict: "args": self.args, "results": context.metadata.get("script_results", {}), } - if target.env is not None: - script_input["env"] = target.env + if self.env_id is not None: + script_input["env"] = _env_on_host(self._deployed_env(context), target.sandbox) return script_input def _target_kind(self) -> str: @@ -370,12 +370,8 @@ async def _read_output(self, target: _ExecTarget, run_dir: str) -> Any: # --- run targets --- - async def _host_target(self, context: TaskStepContext) -> _ExecTarget: - from agent_env.providers.sandbox_providers.sandbox_provider import ( - build_sandbox_provider, - get_env_sandbox_provider, - ) - + def _deployed_env(self, context: TaskStepContext) -> DeployedSandboxEnv: + """The record of ``env_id``'s deploy, which must be in one of agent-env's sandboxes.""" deployed = next( (d for d in context.deployed_envs if d.env_id == self.env_id), None ) @@ -386,6 +382,15 @@ async def _host_target(self, context: TaskStepContext) -> _ExecTarget: f"Env '{self.env_id}' not found in context.deployed_envs " "(or missing sandbox_id)" ) + return deployed + + async def _host_target(self, context: TaskStepContext) -> _ExecTarget: + from agent_env.providers.sandbox_providers.sandbox_provider import ( + build_sandbox_provider, + get_env_sandbox_provider, + ) + + deployed = self._deployed_env(context) # env sandbox provider, honoring a custom sandbox_type provider = ( build_sandbox_provider(deployed.sandbox_type) @@ -393,7 +398,7 @@ async def _host_target(self, context: TaskStepContext) -> _ExecTarget: else get_env_sandbox_provider() ) sandbox = await provider.get_sandbox(deployed.sandbox_id) - return _HostTarget(sandbox, env=_env_on_host(deployed, sandbox)) + return _HostTarget(sandbox) async def _sandbox_target(self, context: TaskStepContext) -> _ExecTarget: from agent_env.providers.sandbox_providers.sandbox import VmSandbox @@ -510,7 +515,7 @@ def _on_host(url: Optional[str], sandbox: Any) -> Optional[str]: class _ExecTarget(Protocol): - env: Optional[dict] # the env's endpoints, for a script on an env's host + sandbox: Any def run_dir(self, step_id: str) -> str: ... async def reset_dir(self, path: str) -> None: ... @@ -523,7 +528,6 @@ class _HostTarget: """Runs the script on a VM host (an env's or a deployed sandbox's; no agent).""" sandbox: Any - env: Optional[dict] = None def run_dir(self, step_id: str) -> str: # The sandbox's own: runs on the local provider share this machine's /tmp, and teardown removes it there. @@ -548,7 +552,6 @@ class _AgentTarget: sandbox: Any prefix: tuple[str, ...] - env: Optional[dict] = None def run_dir(self, step_id: str) -> str: return f"{_RUN_DIR}/{step_id}" From 7de72aba0a90172aad861cb22c9a68d0880b3bda Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 15 Oct 2026 21:26:49 -0700 Subject: [PATCH 4/5] feat(run_code): give an env-host script the env's address from its card, as its sandbox reaches it, on any provider input["env"] is now {"id", "url", "mcp_url"}: the env's address, where its card is, and the MCP endpoint the card declares, for a gateway, a lone server or any other kind of env. Sandbox.url_on_host turns one of a sandbox's own URLs into the address a process on its host reaches it at, following the host ports and addresses it publishes on, and replaces run_code's private helper. An env in a container sandbox, which has no host to stage on, runs the script in that container instead of failing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../providers/sandbox_providers/sandbox.py | 12 ++ .../task_step/task_steps/run_code.py | 43 +++---- .../cli/run_bundle_built_envs_test.py | 16 ++- .../sandbox_providers/test_url_rewrites.py | 64 +++++++++- tst/unit/task_step/test_run_code.py | 115 ++++++++++++------ 5 files changed, 176 insertions(+), 74 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 565f7cbb..81714474 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -169,6 +169,18 @@ def url_from_sandbox(self, url: str) -> str: """``url``, of a service this process reaches, as this sandbox reaches it.""" return url + def url_on_host(self, url: str) -> str: + """``url``, of a service this sandbox publishes, as a process on its host (or in it, for a container) reaches + it: one under a tunnel goes to the host port the tunnel's port is published on, and any other is left alone.""" + address = "127.0.0.1" if not self.host_ips or "127.0.0.1" in self.host_ips else self.host_ips[0] + address = f"[{address}]" if ":" in address else address + # The longest first, so a tunnel at a path under another's takes its own URLs. + for port, tunnel in sorted(self.tunnel_urls.items(), key=lambda item: -len(item[1])): + base = tunnel.rstrip("/") + if url == base or url.startswith(f"{base}/"): + return f"http://{address}:{self.host_port(port)}{url[len(base):]}" + return url + _VM_READY_TIMEOUT = 1200 # wait_for_vm wall-clock budget (s) _VM_READY_POLL_INTERVAL = 30 # sparse polling (s) diff --git a/src/agent_env/task_step/task_steps/run_code.py b/src/agent_env/task_step/task_steps/run_code.py index 03fb1d5c..a190398e 100644 --- a/src/agent_env/task_step/task_steps/run_code.py +++ b/src/agent_env/task_step/task_steps/run_code.py @@ -2,18 +2,17 @@ The script defines ``run(input) -> JSON``, where ``input = {"args", "results"}``. The result is stored at ``context.metadata["script_results"][result_id]``. -A script on an env's host also gets ``input["env"] = {"id", "gateway_url", "mcp_url"}``: -the env's endpoints as that host reaches them, on loopback at the ports they're published -on, since a host can't always call its own public URL. ``gateway_url`` is None for an env -with no gateway. +A script on an env's host also gets ``input["env"] = {"id", "url", "mcp_url"}``: the env's +address, where its card is at ``/.well-known/agent-env.json``, and its MCP endpoint, as that +host reaches them (``Sandbox.url_on_host``), since a host can't always call its own public URL. ``script_artifact_id`` is either a single-file ``FileArtifact`` or a ``FileArtifactUniverse`` whose ``.py`` members are staged side by side so the entry file can import its siblings; non-``.py`` members are skipped. ``script_file`` names the entry, defaulting to the sole ``.py`` member or to ``run.py``. -Target: with ``env_id`` set, the script runs on that env's VM host (no agent, no -LLM key); with ``sandbox_name`` set, on a ``deploy_sandbox`` sandbox (its VM host, or its +Target: with ``env_id`` set, the script runs on that env's VM host, or in its container for an +env in a container sandbox, whose image needs python3 (no agent, no LLM key); with ``sandbox_name`` set, on a ``deploy_sandbox`` sandbox (its VM host, or its container for a container-mode sandbox); otherwise it runs inside the agent container (``agent_name``), where it can see the agent's workspace. @@ -32,7 +31,7 @@ from agent_env.artifact import Artifact, FileArtifact from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse -from agent_env.env.env import DeployedGatewayEnv, DeployedSandboxEnv, require_sandbox +from agent_env.env.env import DeployedSandboxEnv, require_sandbox from agent_env.task_step.context import TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -311,7 +310,7 @@ def _target_kind(self) -> str: return "agent" async def _resolve_target(self, context: TaskStepContext) -> _ExecTarget: - """Pick where the script runs: the env's VM host, a deployed sandbox's host, or the agent container.""" + """Pick where the script runs: the env's host, a deployed sandbox's host, or the agent container.""" if self.env_id is not None: return await self._host_target(context) if self.sandbox_name is not None: @@ -385,6 +384,7 @@ def _deployed_env(self, context: TaskStepContext) -> DeployedSandboxEnv: return deployed async def _host_target(self, context: TaskStepContext) -> _ExecTarget: + from agent_env.providers.sandbox_providers.sandbox import VmSandbox from agent_env.providers.sandbox_providers.sandbox_provider import ( build_sandbox_provider, get_env_sandbox_provider, @@ -398,7 +398,8 @@ async def _host_target(self, context: TaskStepContext) -> _ExecTarget: else get_env_sandbox_provider() ) sandbox = await provider.get_sandbox(deployed.sandbox_id) - return _HostTarget(sandbox) + # An env in a container sandbox has no host to run on: the script runs in the container, beside its server. + return _HostTarget(sandbox) if isinstance(sandbox, VmSandbox) else _AgentTarget(sandbox, prefix=()) async def _sandbox_target(self, context: TaskStepContext) -> _ExecTarget: from agent_env.providers.sandbox_providers.sandbox import VmSandbox @@ -492,28 +493,16 @@ def _is_safe_member(name: str) -> bool: return ".." not in PurePosixPath(name).parts -def _env_on_host(deployed: Any, sandbox: Any) -> dict: - """The env's id and endpoints as a script on its host reaches them.""" - gateway_url = deployed.gateway_url if isinstance(deployed, DeployedGatewayEnv) else None +def _env_on_host(deployed: DeployedSandboxEnv, sandbox: Any) -> dict: + """The env's id, address and MCP endpoint as a script on its host reaches them.""" + url, mcp_url = deployed.environment_url, deployed.mcp_url return { "id": deployed.env_id, - "gateway_url": _on_host(gateway_url, sandbox), - "mcp_url": _on_host(deployed.mcp_url, sandbox), + "url": url and sandbox.url_on_host(url), + "mcp_url": mcp_url and sandbox.url_on_host(mcp_url), } -def _on_host(url: Optional[str], sandbox: Any) -> Optional[str]: - """``url`` as ``sandbox``'s own host reaches it: one under a tunnel goes to loopback at the port it publishes.""" - if url is None: - return None - # The longest first, so a tunnel at a path under another's takes its own URLs. - for port, tunnel in sorted(sandbox.tunnel_urls.items(), key=lambda item: -len(item[1])): - base = tunnel.rstrip("/") - if url == base or url.startswith(f"{base}/"): - return f"http://127.0.0.1:{sandbox.host_port(port)}{url[len(base):]}" - return url - - class _ExecTarget(Protocol): sandbox: Any @@ -548,7 +537,7 @@ async def run(self, *command: str) -> tuple[int, str, str]: @dataclass class _AgentTarget: - """Runs the script inside the agent container.""" + """Runs the script inside a container: an agent's, a container sandbox's, or an env's in one.""" sandbox: Any prefix: tuple[str, ...] diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index 5eb6858b..0cfd0466 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -575,16 +575,16 @@ def _store_echo_server() -> None: MCPServerEnv.put(id="echo-server", docker_image_artifact=image, environment_name="echo") -# Fetches the env's card from the gateway and opens an MCP session at the env's endpoint, both at the addresses its -# input gives: on the local provider, the host ports the sandbox mapped, not the gateway's own port. +# Fetches the env's card at its address and opens an MCP session at its endpoint, both as its input gives them: on the +# local provider, the host port the sandbox mapped, not the port the env serves on. _REACH = """\ import json, urllib.parse, urllib.request def run(inp): env = inp["env"] - gateway = urllib.parse.urlsplit(env["gateway_url"]) - assert (gateway.hostname, gateway.port != 18765) == ("127.0.0.1", True), env - with urllib.request.urlopen(env["gateway_url"] + "/.well-known/agent-env.json", timeout=30) as response: + address = urllib.parse.urlsplit(env["url"]) + assert (address.hostname, address.port != 18765) == ("127.0.0.1", True), env + with urllib.request.urlopen(env["url"] + "/.well-known/agent-env.json", timeout=30) as response: card = json.load(response) initialize = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reach", "version": "1"}}} @@ -596,9 +596,13 @@ def run(inp): """ -def test_a_script_on_an_envs_host_reaches_its_gateway_and_mcp_endpoint_at_the_addresses_its_input_gives(state): +@pytest.mark.parametrize("env_provider_type", ["gateway", "server"]) +def test_a_script_on_an_envs_host_reaches_it_and_its_mcp_endpoint_at_the_addresses_its_input_gives( + state, env_provider_type +): root = state / "reach" shutil.copytree(ECHO / "envs", root / "envs") + (root / "envs" / "echo" / "env.toml").write_text(f'env_provider_type = "{env_provider_type}"\n') (root / "artifacts" / "reach").mkdir(parents=True) (root / "artifacts" / "reach" / "run.py").write_text(_REACH) (root / "tasks").mkdir() diff --git a/tst/unit/providers/sandbox_providers/test_url_rewrites.py b/tst/unit/providers/sandbox_providers/test_url_rewrites.py index 20f5da4a..73ce2812 100644 --- a/tst/unit/providers/sandbox_providers/test_url_rewrites.py +++ b/tst/unit/providers/sandbox_providers/test_url_rewrites.py @@ -7,8 +7,10 @@ from agent_env.config import reset_config from agent_env.env.env import DeployedEnv, DeployedSandboxEnv -from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider, host_url_for +from agent_env.providers.sandbox_providers import local_sandbox as local_sandbox_module +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, LocalSandboxProvider, host_url_for from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider +from agent_env.providers.sandbox_providers.sandbox import Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import ( SandboxProvider, all_sandbox_url_rewrites, @@ -210,4 +212,62 @@ def test_choose_mcp_url_matrix(env_type, agent_type, rewritten): def test_an_env_outside_our_sandboxes_keeps_its_own_mcp_url(): hosted = DeployedEnv(env_id="e", env_version=1, env_provider_type="hosted", mcp_url=f"https://{_INTERNAL}/mcp") - assert _choose_mcp_url(_record("modal"), hosted) == f"https://{_INTERNAL}/mcp" \ No newline at end of file + assert _choose_mcp_url(_record("modal"), hosted) == f"https://{_INTERNAL}/mcp" + +# --- a sandbox's own URLs, as its host reaches them ------------------------------ + + +class _Published(Sandbox): + """A sandbox's tunnels, the addresses it publishes on and its host ports, as a provider reports them.""" + + type = "published" + mode = "vm" + vnc_url = None + + def __init__(self, tunnel_urls, host_ips=(), port_map=None): + self.sandbox_id = "sb" + self.tunnel_urls = tunnel_urls + self.host_ips = host_ips + self._port_map = port_map or {} + + def host_port(self, port): + return self._port_map.get(port, port) + + async def terminate(self): # pragma: no cover - never provisioned here + pass + + +_PROXY = {80: "https://proxy.example/sb/vm-1", 18765: "https://proxy.example/sb/vm-1/port/18765/"} + + +@pytest.mark.parametrize("tunnels,url,expected", [ + ({18765: "https://abc.modal.host"}, "https://abc.modal.host", "http://127.0.0.1:18765"), + ({18765: "https://abc.modal.host"}, "https://abc.modal.host/mcp", "http://127.0.0.1:18765/mcp"), + # a proxy's path prefix, with another tunnel at a path above it + (_PROXY, "https://proxy.example/sb/vm-1/port/18765/mcp", "http://127.0.0.1:18765/mcp"), + (_PROXY, "https://proxy.example/sb/vm-1/web/", "http://127.0.0.1:80/web/"), + # a host that only starts like the tunnel's, and one elsewhere + ({18765: "https://abc.modal.host"}, "https://abc.modal.hostile/mcp", "https://abc.modal.hostile/mcp"), + ({18765: "https://abc.modal.host"}, "https://elsewhere.example/mcp", "https://elsewhere.example/mcp"), +]) +def test_a_url_under_a_sandboxs_tunnel_is_reached_at_the_port_it_publishes(tunnels, url, expected): + assert _Published(tunnels).url_on_host(url) == expected + + +@pytest.mark.parametrize("host_ips,expected", [ + ((), "http://127.0.0.1:23456/mcp"), + (("127.0.0.1", "172.17.0.1"), "http://127.0.0.1:23456/mcp"), + (("10.0.0.5",), "http://10.0.0.5:23456/mcp"), + (("fd00::5",), "http://[fd00::5]:23456/mcp"), +]) +def test_a_sandbox_is_reached_on_the_address_it_publishes_on(host_ips, expected): + sandbox = _Published({18765: "https://abc.modal.host"}, host_ips, port_map={18765: 23456}) + + assert sandbox.url_on_host("https://abc.modal.host/mcp") == expected + + +def test_a_local_sandbox_is_reached_at_the_host_port_it_mapped(tmp_path, monkeypatch): + monkeypatch.setattr(local_sandbox_module, "_host_ips", lambda: ("127.0.0.1", "172.17.0.1")) + sandbox = LocalSandbox(work_dir=tmp_path, port_map={18765: 23456}) + + assert sandbox.url_on_host(f"{sandbox.tunnel_urls[18765]}/mcp") == "http://127.0.0.1:23456/mcp" diff --git a/tst/unit/task_step/test_run_code.py b/tst/unit/task_step/test_run_code.py index 5d21b541..0d6393ea 100644 --- a/tst/unit/task_step/test_run_code.py +++ b/tst/unit/task_step/test_run_code.py @@ -21,7 +21,7 @@ from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, DeployedSandboxEnv, EnvNeedsSandbox from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox -from agent_env.providers.sandbox_providers.sandbox import VmSandbox +from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox from agent_env.task_step.context import DeployedAgent, DeployedSandbox, TaskStepContext from agent_env.task_step.task_steps import run_code as _run_code_module from tst.unit.event_loop_probe import on_event_loop @@ -104,13 +104,16 @@ def _get_member(artifact_id, version=None): def _vm_sandbox_mock(): - """A VM sandbox at the boundary; tunnels and host ports are plain attributes of a real one.""" - sandbox = AsyncMock() + """A VM sandbox at the boundary; tunnels and host ports are plain attributes of a real one, which reaches its own + URLs as the base class does.""" + sandbox = AsyncMock(spec=VmSandbox) sandbox.mode = "vm" sandbox.container_name = "agent-api" sandbox.tunnel_urls = {} + sandbox.host_ips = () sandbox.host_port = MagicMock(side_effect=lambda port: port) sandbox.scoped_name = MagicMock(side_effect=lambda name: name) + sandbox.url_on_host = MagicMock(side_effect=lambda url: Sandbox.url_on_host(sandbox, url)) return sandbox @@ -258,46 +261,54 @@ async def test_host_target_on_an_env_outside_our_sandboxes_says_it_needs_one(): await _step(env_id="env-x", result_id="filter")._host_target(ctx) -class _Tunnels: - """A sandbox's tunnels and host ports, as a provider reports them.""" +class _ContainerEnvSandbox(Sandbox): + """A container sandbox, as Modal's is, whose commands really run here: it has no host to stage on or run scripts on.""" - def __init__(self, tunnel_urls, port_map=None): - self.tunnel_urls = tunnel_urls - self._port_map = port_map or {} - - def host_port(self, port): - return self._port_map.get(port, port) - - -@pytest.mark.parametrize("tunnels,url,expected", [ - ({18765: "https://abc.modal.host"}, "https://abc.modal.host", "http://127.0.0.1:18765"), - ({18765: "https://abc.modal.host"}, "https://abc.modal.host/mcp", "http://127.0.0.1:18765/mcp"), - # a proxy's path prefix, with another tunnel at a path above it - ({80: "https://proxy.example/sb/vm-1", 18765: "https://proxy.example/sb/vm-1/port/18765/"}, - "https://proxy.example/sb/vm-1/port/18765/mcp", "http://127.0.0.1:18765/mcp"), - ({80: "https://proxy.example/sb/vm-1", 18765: "https://proxy.example/sb/vm-1/port/18765/"}, - "https://proxy.example/sb/vm-1/web/", "http://127.0.0.1:80/web/"), - ({18765: "https://abc.modal.host"}, "https://elsewhere.example/mcp", "https://elsewhere.example/mcp"), - ({18765: "http://127.0.0.1:2000"}, "http://127.0.0.1:20001/mcp", "http://127.0.0.1:20001/mcp"), - ({18765: "https://abc.modal.host"}, None, None), -]) -def test_a_url_under_its_hosts_tunnel_is_reached_on_loopback_at_the_published_port(tunnels, url, expected): - assert _run_code_module._on_host(url, _Tunnels(tunnels)) == expected + type = "modal" + mode = "container" + def __init__(self, tunnel_urls): + self.sandbox_id = "sb-env" + self.tunnel_urls = tunnel_urls + self.vnc_url = None + self.commands = [] -def test_a_local_sandbox_reaches_its_gateway_at_the_host_port_it_mapped(tmp_path): - sandbox = LocalSandbox(work_dir=tmp_path, port_map={18765: 23456}) - gateway = sandbox.tunnel_urls[18765] + async def terminate(self): + pass - assert _run_code_module._on_host(f"{gateway}/mcp", sandbox) == "http://127.0.0.1:23456/mcp" + async def write_file_from_text(self, content, path): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(content) + async def exec_with_output(self, *args): + self.commands.append(args) + proc = subprocess.run(list(args), capture_output=True) + return proc.returncode, proc.stdout.decode(), proc.stderr.decode() -def test_an_env_with_no_gateway_gives_its_mcp_url_alone(): - deployed = DeployedSandboxEnv(env_id="srv", env_version=1, sandbox_id="sb", mcp_url="https://abc.modal.host/mcp") - env = _run_code_module._env_on_host(deployed, _Tunnels({8000: "https://abc.modal.host"})) +_CARD_URL = "https://abc.modal.host/.well-known/agent-env.json" + + +@pytest.mark.parametrize("deployed,expected", [ + (DeployedGatewayEnv(env_id="gw", env_version=1, sandbox_id="sb", gateway_url="https://abc.modal.host", + environment_card_url=_CARD_URL, environment_card={"name": "gw"}), + {"id": "gw", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp"}), + (DeployedSandboxEnv(env_id="srv", env_version=1, sandbox_id="sb", env_provider_type="server", + environment_card_url=_CARD_URL, environment_card={"name": "srv"}), + {"id": "srv", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp"}), + # a plugin's env, whose card puts its MCP endpoint elsewhere + (DeployedSandboxEnv(env_id="plug", env_version=1, sandbox_id="sb", env_provider_type="a-plugins", + environment_card_url=_CARD_URL, + environment_card={"name": "plug", "additionalInterfaces": [{"transport": "mcp", "url": "/v2/mcp"}]}), + {"id": "plug", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/v2/mcp"}), + # a record from before the card, which kept only its MCP URL + (DeployedSandboxEnv(env_id="old", env_version=1, sandbox_id="sb", mcp_url="https://abc.modal.host/mcp"), + {"id": "old", "url": None, "mcp_url": "http://127.0.0.1:18765/mcp"}), +]) +def test_an_envs_address_and_mcp_endpoint_come_from_its_card_whatever_deployed_it(deployed, expected): + sandbox = _ContainerEnvSandbox({18765: "https://abc.modal.host"}) - assert env == {"id": "srv", "gateway_url": None, "mcp_url": "http://127.0.0.1:8000/mcp"} + assert _run_code_module._env_on_host(deployed, sandbox) == expected def _written_input(sandbox): @@ -312,17 +323,43 @@ def _written_input(sandbox): @pytest.mark.asyncio -async def test_a_script_on_an_envs_host_gets_the_envs_endpoints_as_that_host_reaches_them(): +async def test_a_script_on_an_envs_host_gets_the_envs_address_and_mcp_endpoint_as_that_host_reaches_them(): ctx = _make_context(with_agent=False) - ctx.deployed_envs[0].gateway_url = "https://abc.modal.host" - ctx.deployed_envs[0].mcp_url = "https://abc.modal.host/mcp" + ctx.deployed_envs[0] = DeployedGatewayEnv( + env_id="env-x", env_version=1, sandbox_id="sb-env", gateway_url="https://abc.modal.host", + environment_card_url=_CARD_URL, environment_card={"name": "env-x"}, + ) with _sandbox([(0, "", ""), (0, "2", ""), (0, "{}", "")], agent_mode=False) as sandbox: sandbox.tunnel_urls = {18765: "https://abc.modal.host"} await _step(env_id="env-x").execute(ctx) assert _written_input(sandbox)["env"] == { - "id": "env-x", "gateway_url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp", + "id": "env-x", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp", + } + + +@pytest.mark.asyncio +async def test_a_script_for_an_env_in_a_container_sandbox_runs_in_that_container_and_reaches_the_env_there(tmp_path): + sandbox = _ContainerEnvSandbox({18765: "https://abc.modal.host"}) + provider = MagicMock() + provider.get_sandbox = AsyncMock(return_value=sandbox) + ctx = _make_context(with_agent=False) + ctx.deployed_envs = [DeployedSandboxEnv( + env_id="env-x", env_version=1, sandbox_id="sb-env", env_provider_type="server", + environment_card_url=_CARD_URL, environment_card={"name": "srv"}, + )] + artifact = _file_artifact(body=b"def run(inp):\n return inp['env']\n") + with patch( + "agent_env.providers.sandbox_providers.sandbox_provider.get_env_sandbox_provider", return_value=provider, + ), patch("agent_env.artifact.Artifact.get", return_value=artifact), patch.object( + FileArtifact, "load", _load_body + ), patch.object(_run_code_module, "_RUN_DIR", str(tmp_path)): + ctx = await _step(env_id="env-x").execute(ctx) + + assert ctx.metadata["script_results"]["run-code-1"] == { + "id": "env-x", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/mcp", } + assert not any(command[0] in ("sudo", "docker") for command in sandbox.commands) @pytest.mark.asyncio From 1bbbbc9447feac785221839296a8745d7fe65d71 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 15 Oct 2026 21:38:20 -0700 Subject: [PATCH 5/5] style(run_code): wrap the module docstring and two test lines within 120 columns Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/task_step/task_steps/run_code.py | 8 ++++---- tst/unit/task_step/test_run_code.py | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/agent_env/task_step/task_steps/run_code.py b/src/agent_env/task_step/task_steps/run_code.py index a190398e..f71ea335 100644 --- a/src/agent_env/task_step/task_steps/run_code.py +++ b/src/agent_env/task_step/task_steps/run_code.py @@ -12,10 +12,10 @@ the entry, defaulting to the sole ``.py`` member or to ``run.py``. Target: with ``env_id`` set, the script runs on that env's VM host, or in its container for an -env in a container sandbox, whose image needs python3 (no agent, no LLM key); with ``sandbox_name`` set, on a ``deploy_sandbox`` sandbox (its VM host, or its -container for a container-mode sandbox); -otherwise it runs inside the agent container (``agent_name``), where it can see the -agent's workspace. +env in a container sandbox, whose image needs python3 (no agent, no LLM key); with +``sandbox_name`` set, on a ``deploy_sandbox`` sandbox (its VM host, or its container for a +container-mode sandbox); otherwise it runs inside the agent container (``agent_name``), where +it can see the agent's workspace. """ from __future__ import annotations diff --git a/tst/unit/task_step/test_run_code.py b/tst/unit/task_step/test_run_code.py index 0d6393ea..ad544628 100644 --- a/tst/unit/task_step/test_run_code.py +++ b/tst/unit/task_step/test_run_code.py @@ -262,7 +262,7 @@ async def test_host_target_on_an_env_outside_our_sandboxes_says_it_needs_one(): class _ContainerEnvSandbox(Sandbox): - """A container sandbox, as Modal's is, whose commands really run here: it has no host to stage on or run scripts on.""" + """A container sandbox, as Modal's is, whose commands really run here; it has no host to stage on or run on.""" type = "modal" mode = "container" @@ -299,7 +299,8 @@ async def exec_with_output(self, *args): # a plugin's env, whose card puts its MCP endpoint elsewhere (DeployedSandboxEnv(env_id="plug", env_version=1, sandbox_id="sb", env_provider_type="a-plugins", environment_card_url=_CARD_URL, - environment_card={"name": "plug", "additionalInterfaces": [{"transport": "mcp", "url": "/v2/mcp"}]}), + environment_card={"name": "plug", + "additionalInterfaces": [{"transport": "mcp", "url": "/v2/mcp"}]}), {"id": "plug", "url": "http://127.0.0.1:18765", "mcp_url": "http://127.0.0.1:18765/v2/mcp"}), # a record from before the card, which kept only its MCP URL (DeployedSandboxEnv(env_id="old", env_version=1, sandbox_id="sb", mcp_url="https://abc.modal.host/mcp"),