diff --git a/docs/reference/config.md b/docs/reference/config.md index 6f262d0..b625714 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -90,7 +90,9 @@ telemetry: `transport` is `stdio` or `http` in gateway config files. Legacy `streamable-http` and `streamable_http` values are still accepted as aliases for `http`. -The `workspace_root` default controls where sandboxed scripts run. If that +The `workspace_root` default controls where sandboxed scripts run. When omitted, +a gateway using the `stdio` transport defaults it to the gateway process's +current working directory. Other transports use a temporary workspace. If the workspace contains `.venv/bin/python` or `.venv/Scripts/python.exe`, the gateway uses it for sandbox execution. diff --git a/src/nomad/gateway/sandbox.py b/src/nomad/gateway/sandbox.py index 448f20a..acdd990 100644 --- a/src/nomad/gateway/sandbox.py +++ b/src/nomad/gateway/sandbox.py @@ -466,6 +466,14 @@ async def run_script( script_args: list[str] = (), capture_stdio: bool = True, ) -> SandboxResult: + script_path = Path(script_path).expanduser() + if not script_path.is_absolute(): + script_path = (options.workspace_root or Path.cwd()) / script_path + + script_path = script_path.absolute() + if not script_path.is_file(): + raise FileNotFoundError(f"Script file not found: {script_path}") + return await self._run_script_path( script_path=script_path, options=options, diff --git a/src/nomad/gateway/server.py b/src/nomad/gateway/server.py index d87ffe3..9652748 100644 --- a/src/nomad/gateway/server.py +++ b/src/nomad/gateway/server.py @@ -421,6 +421,13 @@ def run_gateway( **kwargs: Any, ) -> None: """Run a code-mode gateway from an already-loaded config.""" + effective_transport = transport or "stdio" + if ( + effective_transport == "stdio" + and "workspace_root" not in config.defaults.model_fields_set + ): + config.defaults.workspace_root = Path.cwd().resolve() + configure_otel( service_name=config.telemetry.service_name, service_version=_PACKAGE_VERSION, diff --git a/test/gateway/test_cli.py b/test/gateway/test_cli.py index 14de15c..064095a 100644 --- a/test/gateway/test_cli.py +++ b/test/gateway/test_cli.py @@ -11,12 +11,71 @@ from nomad.common.config_errors import ConfigError from nomad.common.upstream_errors import UpstreamConnectionError -from nomad.gateway import cli -from nomad.gateway.config import GatewayConfig +from nomad.gateway import cli, server +from nomad.gateway.config import GatewayConfig, GatewayDefaults runner = CliRunner() +class _FakeCodeModeGateway: + created_with: GatewayConfig | None = None + + def __init__(self, config: GatewayConfig): + type(self).created_with = config + + async def serve(self, transport=None, **kwargs): + return None + + +@pytest.mark.parametrize("transport", [None, "stdio"]) +def test_stdio_defaults_workspace_to_cwd( + monkeypatch, + tmp_path: Path, + transport: str | None, +): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(server, "CodeModeGateway", _FakeCodeModeGateway) + config = GatewayConfig(servers={}) + + server.run_gateway(config, transport=transport) + + created = _FakeCodeModeGateway.created_with + assert created is not None + assert created.defaults.workspace_root == tmp_path.resolve() + + +def test_stdio_preserves_configured_workspace(monkeypatch, tmp_path: Path): + launch_dir = tmp_path / "launch" + launch_dir.mkdir() + configured_workspace = tmp_path / "configured" + monkeypatch.chdir(launch_dir) + monkeypatch.setattr(server, "CodeModeGateway", _FakeCodeModeGateway) + config = GatewayConfig( + servers={}, + defaults=GatewayDefaults(workspace_root=configured_workspace), + ) + + server.run_gateway(config, transport="stdio") + + created = _FakeCodeModeGateway.created_with + assert created is not None + assert created.defaults.workspace_root == configured_workspace + + +def test_http_keeps_temporary_workspace_default(monkeypatch, tmp_path: Path): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(server, "CodeModeGateway", _FakeCodeModeGateway) + config = GatewayConfig(servers={}) + temporary_workspace = config.defaults.workspace_root + + server.run_gateway(config, transport="http") + + created = _FakeCodeModeGateway.created_with + assert created is not None + assert created.defaults.workspace_root == temporary_workspace + assert created.defaults.workspace_root != tmp_path.resolve() + + @pytest.fixture def dummy_config(tmp_path: Path) -> Path: path = tmp_path / "config.yaml" diff --git a/test/gateway/test_gateway.py b/test/gateway/test_gateway.py index a30f140..7de534c 100644 --- a/test/gateway/test_gateway.py +++ b/test/gateway/test_gateway.py @@ -585,6 +585,81 @@ async def test_execute_script_reads_and_runs_file( assert "tool_calls" not in data +@pytest.mark.asyncio +async def test_run_script_resolves_relative_path_from_workspace( + gateway: GatewayHarness, +): + workspace_root = gateway.gateway.config.defaults.workspace_root + assert workspace_root is not None + script_path = workspace_root / "relative.py" + script_path.write_text("RESULT = __file__\n", encoding="utf-8") + + result = await gateway.gateway.run_script(Path("relative.py")) + + assert Path(result.result).absolute() == script_path.absolute() + + +@pytest.mark.asyncio +async def test_run_script_expands_user_path( + gateway: GatewayHarness, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + script_path = tmp_path / "home_script.py" + script_path.write_text("RESULT = __file__\n", encoding="utf-8") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + result = await gateway.gateway.run_script(Path("~/home_script.py")) + + assert Path(result.result).absolute() == script_path.absolute() + + +@pytest.mark.asyncio +async def test_run_script_resolves_relative_path_from_cwd_without_workspace( + gateway: GatewayHarness, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + script_path = tmp_path / "relative.py" + script_path.write_text("RESULT = __file__\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + gateway.gateway.config.defaults.workspace_root = None + + result = await gateway.gateway.run_script(Path("relative.py")) + + assert Path(result.result).absolute() == script_path.absolute() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_name", ["missing.py", "directory"]) +async def test_run_script_rejects_paths_that_are_not_files( + gateway: GatewayHarness, + invalid_name: str, +): + workspace_root = gateway.gateway.config.defaults.workspace_root + assert workspace_root is not None + (workspace_root / "directory").mkdir(exist_ok=True) + + with pytest.raises(FileNotFoundError, match="Script file not found"): + await gateway.gateway.run_script(Path(invalid_name)) + + +@pytest.mark.asyncio +async def test_execute_script_reports_missing_file_to_client( + gateway: GatewayHarness, +): + payload = await gateway.client.call_tool( + "execute_mcp_script", + {"script_path": "missing.py"}, + raise_on_error=False, + ) + + assert payload.is_error + assert payload.content + assert "Script file not found" in payload.content[0].text + + @pytest.mark.asyncio async def test_execute_script_preserves_file_context_for_package_scripts( gateway: GatewayHarness, tmp_path: Path