Skip to content
Draft
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
8 changes: 7 additions & 1 deletion crates/eryx-python/python/eryx/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import eryx

from eryx._cli import add_sandbox_args, make_mcp_manager, make_net_config, make_resource_limits
from eryx._cli import add_sandbox_args, make_mcp_manager, make_net_config, make_resource_limits, make_secrets


def _build_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -90,6 +90,9 @@ def _run_once(code: str, args: argparse.Namespace, mcp_manager: object | None =
kwargs["resource_limits"] = limits
if net is not None:
kwargs["network"] = net
secrets = make_secrets(args)
if secrets is not None:
kwargs["secrets"] = secrets
if args.volume:
kwargs["volumes"] = args.volume
if mcp_manager is not None:
Expand Down Expand Up @@ -127,6 +130,9 @@ def _repl(args: argparse.Namespace, mcp_manager: object | None = None) -> int:
net = make_net_config(args)
if net is not None:
kwargs["network"] = net
secrets = make_secrets(args)
if secrets is not None:
kwargs["secrets"] = secrets
if args.volume:
kwargs["volumes"] = args.volume
if mcp_manager is not None:
Expand Down
35 changes: 35 additions & 0 deletions crates/eryx-python/python/eryx/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import os

import eryx

Expand Down Expand Up @@ -73,6 +74,17 @@ def add_sandbox_args(parser: argparse.ArgumentParser) -> None:
help="path to MCP config file (implies --mcp, can be repeated)",
)

env = parser.add_argument_group("environment")
env.add_argument(
"-e",
"--env",
action="append",
default=[],
metavar="KEY[=VALUE]",
help="pass environment variable to sandbox (scrubbed from output). "
"Use KEY=VALUE to set explicitly, or KEY to inherit from host",
)

fs = parser.add_argument_group("filesystem")
fs.add_argument(
"-v",
Expand Down Expand Up @@ -107,6 +119,29 @@ def make_net_config(args: argparse.Namespace) -> eryx.NetConfig | None:
return config


def make_secrets(args: argparse.Namespace) -> dict | None:
"""Build secrets dict from -e/--env CLI args, or None if no env vars.

Each -e spec is either KEY=VALUE (explicit) or KEY (inherit from host).
Values are passed as scrubbed secrets so they're redacted in output.
"""
if not args.env:
return None
secrets = {}
for spec in args.env:
if "=" in spec:
key, value = spec.split("=", 1)
else:
key = spec
value = os.environ.get(key)
if value is None:
raise argparse.ArgumentTypeError(
f"environment variable '{key}' is not set"
)
secrets[key] = {"value": value}
return secrets


def make_mcp_manager(args: argparse.Namespace) -> object | None:
"""Create an MCP manager if --mcp or --mcp-config is specified."""
if not args.mcp and not args.mcp_config:
Expand Down
98 changes: 98 additions & 0 deletions crates/eryx-python/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the eryx CLI (__main__.py)."""

import os
import subprocess
import sys
import textwrap
Expand All @@ -8,6 +9,7 @@
import pytest

from eryx.__main__ import main
from eryx._cli import make_secrets


class TestCliCommandExecution:
Expand Down Expand Up @@ -197,3 +199,99 @@ def test_piped_stdin_without_dash(self, capsys):
assert result == 0
captured = capsys.readouterr()
assert "piped" in captured.out


class TestMakeSecrets:
"""Tests for the make_secrets helper."""

def test_no_env_returns_none(self):
args = _make_args(env=[])
assert make_secrets(args) is None

def test_explicit_key_value(self):
args = _make_args(env=["MY_KEY=my_value"])
secrets = make_secrets(args)
assert secrets == {"MY_KEY": {"value": "my_value"}}

def test_value_with_equals(self):
args = _make_args(env=["DSN=postgres://user:pass@host/db"])
secrets = make_secrets(args)
assert secrets == {"DSN": {"value": "postgres://user:pass@host/db"}}

def test_empty_value(self):
args = _make_args(env=["EMPTY="])
secrets = make_secrets(args)
assert secrets == {"EMPTY": {"value": ""}}

def test_inherit_from_host(self):
with patch.dict(os.environ, {"HOST_VAR": "host_value"}):
args = _make_args(env=["HOST_VAR"])
secrets = make_secrets(args)
assert secrets == {"HOST_VAR": {"value": "host_value"}}

def test_inherit_missing_var_raises(self):
# Ensure the var is not set
env = os.environ.copy()
env.pop("DEFINITELY_NOT_SET_12345", None)
with patch.dict(os.environ, env, clear=True):
args = _make_args(env=["DEFINITELY_NOT_SET_12345"])
with pytest.raises(Exception, match="not set"):
make_secrets(args)

def test_multiple_env_vars(self):
args = _make_args(env=["A=1", "B=2"])
secrets = make_secrets(args)
assert secrets == {"A": {"value": "1"}, "B": {"value": "2"}}


class TestCliEnvFlag:
"""Integration tests for -e/--env CLI flag."""

def test_env_var_available_in_sandbox(self, capsys):
result = main(["-e", "TEST_VAR=hello_sandbox", "-c", "import os; print(os.environ['TEST_VAR'])"])
assert result == 0
captured = capsys.readouterr()
# Value should be scrubbed (it's a secret)
assert "hello_sandbox" not in captured.out
assert "[REDACTED]" in captured.out

def test_env_var_value_is_scrubbed(self, capsys):
result = main(["-e", "SECRET=super_secret_42", "-c", "import os; print(os.environ['SECRET'])"])
assert result == 0
captured = capsys.readouterr()
assert "super_secret_42" not in captured.out
assert "[REDACTED]" in captured.out

def test_multiple_env_vars(self, capsys):
result = main([
"-e", "VAR_A=aaa",
"-e", "VAR_B=bbb",
"-c", "import os; print(os.environ['VAR_A'], os.environ['VAR_B'])",
])
assert result == 0
captured = capsys.readouterr()
assert "aaa" not in captured.out
assert "bbb" not in captured.out

def test_env_inherit_from_host(self, capsys):
with patch.dict(os.environ, {"MY_HOST_VAR": "from_host"}):
result = main(["-e", "MY_HOST_VAR", "-c", "import os; print(os.environ['MY_HOST_VAR'])"])
assert result == 0
captured = capsys.readouterr()
assert "from_host" not in captured.out
assert "[REDACTED]" in captured.out

def test_long_form_env_flag(self, capsys):
result = main(["--env", "LONGFORM=works", "-c", "import os; print(os.environ['LONGFORM'])"])
assert result == 0
captured = capsys.readouterr()
assert "works" not in captured.out
assert "[REDACTED]" in captured.out


def _make_args(**kwargs):
"""Create a minimal argparse.Namespace for testing."""
import argparse
defaults = {"env": []}
defaults.update(kwargs)
return argparse.Namespace(**defaults)