Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions src/nomad/gateway/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/nomad/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 61 additions & 2 deletions test/gateway/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions test/gateway/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down