diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py index 7cdaedae7a..bbd4f72160 100644 --- a/integration_tests/conftest.py +++ b/integration_tests/conftest.py @@ -25,6 +25,11 @@ import pytest import requests +from egg_container import ( + LIFECYCLE_FLAGS_INDEX, + ContainerNetworkConfig, + build_sandbox_docker_cmd, +) # Project root (one level up from integration_tests/) PROJECT_ROOT = Path(__file__).parent.parent @@ -38,6 +43,10 @@ GATEWAY_PORT = 9848 PROXY_PORT = 3129 +# Counter for allocating unique container IPs within the test subnet. +# Starts at 100 to leave room for gateway (.2) and other infrastructure. +_next_container_ip_suffix = 100 + @dataclass class ContainerInfo: @@ -593,6 +602,25 @@ def passed(self) -> bool: return self.verdict == "pass" +def _allocate_test_container_ip() -> str: + """Allocate a unique IP address for a test container. + + Production uses ``_allocate_container_ip()`` which inspects the docker + network to find available IPs. For tests, we use a simple counter to + avoid the subprocess overhead and race conditions in parallel tests. + + Returns: + An IP in the 172.40.0.100+ range (test isolated subnet). + """ + global _next_container_ip_suffix + ip = f"172.40.0.{_next_container_ip_suffix}" + _next_container_ip_suffix += 1 + # Wrap around if we somehow allocate >155 containers in one session + if _next_container_ip_suffix > 254: + _next_container_ip_suffix = 100 + return ip + + def run_claude_structured( egg_stack: "EggStack", session_token: str, @@ -608,6 +636,15 @@ def run_claude_structured( Uses ``--output-format json`` and ``--json-schema`` to get a parsed verdict back from the agent. The agent identity is established via ``--append-system-prompt`` to separate it from the building agent. + + Network configuration coupling: + This function constructs ``ContainerNetworkConfig`` manually from + ``EggStack`` values rather than calling ``_get_container_network_config()`` + (which is internal to ``sandbox/egg_lib/runtime.py``). If that function + gains new fields or changes defaults, this test helper must be updated + to match. The shared ``build_sandbox_docker_cmd()`` ensures the *docker + arguments* stay in sync, but the *config dataclass population* is a + separate coupling point. """ system_prompt = TEST_AGENT_SYSTEM_PROMPT if extra_system: @@ -615,35 +652,58 @@ def run_claude_structured( schema_json = json.dumps(VERDICT_SCHEMA) - cmd = [ - "docker", - "run", - "--rm", - "--network", - egg_stack.isolated_network, - "--add-host", - f"egg-gateway:{egg_stack.gateway_isolated_ip}", - "-e", - "GATEWAY_URL=http://egg-gateway:9848", - "-e", - f"EGG_SESSION_TOKEN={session_token}", - "egg-sandbox:latest", - "claude", - "--print", - "--output-format", - "json", - "--json-schema", - schema_json, - "--append-system-prompt", - system_prompt, - "--no-session-persistence", - "--max-budget-usd", - str(max_budget_usd), - "--model", - model, - "--dangerously-skip-permissions", - prompt, - ] + net_config = ContainerNetworkConfig( + network_name=egg_stack.isolated_network, + gateway_hostname="egg-gateway", + gateway_ip=egg_stack.gateway_isolated_ip, + gateway_port=GATEWAY_PORT, + repo_mode="private", + proxy_url=f"http://egg-gateway:{PROXY_PORT}", + ) + + # Allocate a static IP for this container — matches production behavior + # where sessions are bound to specific container IPs for request verification. + container_ip = _allocate_test_container_ip() + + cmd = build_sandbox_docker_cmd( + container_name=f"test-claude-{os.getpid()}-{time.time_ns()}", + image="egg-sandbox:latest", + network=net_config, + container_ip=container_ip, + session_token=session_token, + runtime_uid=1000, + runtime_gid=1000, + extra_env={ + "ANTHROPIC_OAUTH_TOKEN": os.environ["ANTHROPIC_OAUTH_TOKEN"], + # Match production: always set auth method so sandbox startup code + # branches the same way in tests as in production. + "ANTHROPIC_AUTH_METHOD": "oauth", + }, + ) + + # Lifecycle flags — use the module constant to avoid hardcoding the index + cmd[LIFECYCLE_FLAGS_INDEX:LIFECYCLE_FLAGS_INDEX] = ["--rm"] + + # Claude CLI command after image name + cmd.extend( + [ + "claude", + "--print", + "--output-format", + "json", + "--json-schema", + schema_json, + "--append-system-prompt", + system_prompt, + "--no-session-persistence", + "--max-budget-usd", + str(max_budget_usd), + "--model", + model, + "--dangerously-skip-permissions", + prompt, + ] + ) try: result = subprocess.run( diff --git a/pyproject.toml b/pyproject.toml index c09636a804..84a44ece7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["gateway", "shared/egg_config", "shared/egg_logging", "shared/egg_git", "sandbox/egg_lib"] +packages = ["gateway", "shared/egg_config", "shared/egg_container", "shared/egg_logging", "shared/egg_git", "sandbox/egg_lib"] [tool.hatch.build.targets.sdist] include = [ diff --git a/sandbox/egg_lib/runtime.py b/sandbox/egg_lib/runtime.py index ecc308168a..5121e44946 100644 --- a/sandbox/egg_lib/runtime.py +++ b/sandbox/egg_lib/runtime.py @@ -23,6 +23,12 @@ from datetime import datetime from pathlib import Path +from egg_container import ( + LIFECYCLE_FLAGS_INDEX, + ContainerNetworkConfig, + build_sandbox_docker_cmd, +) + # Import statusbar for quiet mode from statusbar import status, status_finish @@ -92,7 +98,7 @@ def _validate_repo_mode(repo_mode: str | None) -> None: def _get_container_network_config( repo_mode: str | None, -) -> tuple[str, str, list[str]]: +) -> ContainerNetworkConfig: """Get network configuration for a container based on repo_mode. This centralizes the network selection logic to prevent divergence between @@ -102,49 +108,27 @@ def _get_container_network_config( repo_mode: Repository visibility mode ("private" or "public") Returns: - Tuple of (network_name, gateway_ip, extra_docker_args): - - network_name: Docker network to use (egg-isolated or egg-external) - - gateway_ip: IP address of the gateway on that network - - extra_docker_args: Mode-specific docker arguments (DNS, proxy settings) + A ContainerNetworkConfig with all mode-specific network parameters. """ ctx = get_context() - proxy_url = f"http://{ctx.gateway_container_name}:{ctx.gateway_proxy_port}" if repo_mode == "private": - # PRIVATE: Internal isolated network with proxy (locked to api.anthropic.com) - # DNS is disabled (0.0.0.0) to prevent direct hostname resolution. - # HTTP clients using HTTP_PROXY/HTTPS_PROXY send CONNECT requests to the - # proxy with the hostname, and Squid resolves DNS on behalf of the client. - # If a tool bypasses the proxy, its requests fail with DNS errors (fail closed). - extra_args = [ - # Disable DNS (no external DNS resolution - fail closed) - "--dns", - "0.0.0.0", - # Set PRIVATE_MODE env var so container knows its mode - "-e", - "PRIVATE_MODE=true", - # HTTP/HTTPS proxy environment variables for network lockdown - "-e", - f"HTTP_PROXY={proxy_url}", - "-e", - f"HTTPS_PROXY={proxy_url}", - "-e", - f"http_proxy={proxy_url}", - "-e", - f"https_proxy={proxy_url}", - # Bypass proxy for local connections to gateway - "-e", - f"NO_PROXY=localhost,127.0.0.1,{ctx.gateway_container_name}", - "-e", - f"no_proxy=localhost,127.0.0.1,{ctx.gateway_container_name}", - ] - return ctx.isolated_network, ctx.gateway_isolated_ip, extra_args + return ContainerNetworkConfig( + network_name=ctx.isolated_network, + gateway_hostname=ctx.gateway_container_name, + gateway_ip=ctx.gateway_isolated_ip, + gateway_port=ctx.gateway_port, + repo_mode="private", + proxy_url=f"http://{ctx.gateway_container_name}:{ctx.gateway_proxy_port}", + ) else: - # PUBLIC: External network with direct internet access (no proxy) - # Uses Docker's default DNS. No proxy env vars set. - # Container can access the internet directly. - # Set PRIVATE_MODE=false so container knows its mode - return ctx.external_network, ctx.gateway_external_ip, ["-e", "PRIVATE_MODE=false"] + return ContainerNetworkConfig( + network_name=ctx.external_network, + gateway_hostname=ctx.gateway_container_name, + gateway_ip=ctx.gateway_external_ip, + gateway_port=ctx.gateway_port, + repo_mode="public", + ) def _get_repo_owner_name(repo_path: Path) -> str | None: @@ -585,12 +569,12 @@ def run_claude(repo_mode: str | None = None) -> bool: container_ip = None # Get network configuration based on mode (centralized in helper to prevent divergence) - container_network, gateway_ip, network_extra_args = _get_container_network_config(repo_mode) + net_config = _get_container_network_config(repo_mode) # Choose mount strategy based on repo_mode if repo_mode: # Per-container session mode: allocate IP first for session binding - container_ip = _allocate_container_ip(network=container_network) + container_ip = _allocate_container_ip(network=net_config.network_name) if not container_ip: error("Failed to allocate container IP for session mode") return False @@ -649,53 +633,34 @@ def run_claude(repo_mode: str | None = None) -> bool: # Build docker run command _host_timer.start_phase("build_docker_cmd") - cmd = [ - "docker", - "run", - "--rm", # Auto-remove container after exit - "-it", # Interactive with TTY - "--security-opt", - "label=disable", # Disable SELinux labeling for faster startup - "--name", - container_id, - # Network selection based on mode - "--network", - container_network, - ] - - # If session mode with pre-allocated IP, use static IP assignment - # This binds the container to the session for security verification - if container_ip: - cmd.extend(["--ip", container_ip]) - - # Add gateway hostname for API access (both modes need this) - cmd.extend( - [ - "--add-host", - f"{ctx.gateway_container_name}:{gateway_ip}", - # Environment variables - "-e", - f"RUNTIME_UID={os.getuid()}", - "-e", - f"RUNTIME_GID={os.getgid()}", - "-e", - f"CONTAINER_ID={container_id}", - "-e", - f"EGG_QUIET={'1' if quiet else '0'}", - "-e", - f"EGG_TIMING={'1' if _host_timer.enabled else '0'}", - "-e", - f"GATEWAY_URL=http://{ctx.gateway_container_name}:{ctx.gateway_port}", - ] + # Caller-specific env vars that don't belong in the shared builder + caller_env: dict[str, str] = { + "EGG_QUIET": "1" if quiet else "0", + "EGG_TIMING": "1" if _host_timer.enabled else "0", + } + + # Add Anthropic auth configuration + anthropic_auth_method = get_anthropic_auth_method() + caller_env["ANTHROPIC_AUTH_METHOD"] = anthropic_auth_method + if api_key: + caller_env["ANTHROPIC_API_KEY"] = api_key + + cmd = build_sandbox_docker_cmd( + container_name=container_id, + image=ctx.sandbox_image, + network=net_config, + container_ip=container_ip, + session_token=session_token, + runtime_uid=os.getuid(), + runtime_gid=os.getgid(), + extra_env=caller_env, ) - # Mode-specific network settings (DNS, proxy) from centralized helper - if network_extra_args: - cmd.extend(network_extra_args) + # Insert lifecycle flags after "docker run" + cmd[LIFECYCLE_FLAGS_INDEX:LIFECYCLE_FLAGS_INDEX] = ["--rm", "-it"] - # If session mode, pass session token for container authentication - if session_token: - cmd.extend(["-e", f"EGG_SESSION_TOKEN={session_token}"]) + # Insert mount arguments before the image name (last element) + cmd[-1:-1] = mount_args # GitHub authentication is handled by the gateway sidecar # The container does NOT receive GITHUB_TOKEN - all git/gh operations @@ -703,23 +668,15 @@ def run_claude(repo_mode: str | None = None) -> bool: if not quiet: info("GitHub auth: Via gateway sidecar (credentials not in container)") - # Add Anthropic auth configuration - anthropic_auth_method = get_anthropic_auth_method() - cmd.extend(["-e", f"ANTHROPIC_AUTH_METHOD={anthropic_auth_method}"]) - - # Set Anthropic API key if available - if api_key: - cmd.extend(["-e", f"ANTHROPIC_API_KEY={api_key}"]) - if not quiet: info(f"Claude auth method: {anthropic_auth_method}") if repo_mode == "private": info("Network mode: PRIVATE (isolated network, proxy filtering)") if container_ip: - print(f" Network: {container_network} (IP: {container_ip})") + print(f" Network: {net_config.network_name} (IP: {container_ip})") else: - print(f" Network: {container_network} (IP assigned dynamically)") - print(f" Gateway: {ctx.gateway_container_name} at {gateway_ip}") + print(f" Network: {net_config.network_name} (IP assigned dynamically)") + print(f" Gateway: {ctx.gateway_container_name} at {net_config.gateway_ip}") print(f" Gateway API: http://{ctx.gateway_container_name}:{ctx.gateway_port}") print(f" Proxy: http://{ctx.gateway_container_name}:{ctx.gateway_proxy_port}") print(" Container can: Access Claude API, GitHub (via gateway sidecar)") @@ -727,10 +684,10 @@ def run_claude(repo_mode: str | None = None) -> bool: else: info("Network mode: PUBLIC (direct internet access)") if container_ip: - print(f" Network: {container_network} (IP: {container_ip})") + print(f" Network: {net_config.network_name} (IP: {container_ip})") else: - print(f" Network: {container_network} (IP assigned dynamically)") - print(f" Gateway: {ctx.gateway_container_name} at {gateway_ip}") + print(f" Network: {net_config.network_name} (IP assigned dynamically)") + print(f" Gateway: {ctx.gateway_container_name} at {net_config.gateway_ip}") print(f" Gateway API: http://{ctx.gateway_container_name}:{ctx.gateway_port}") print(" Container can: Access internet directly, GitHub (via gateway sidecar)") print(" Container cannot: Access private repos") @@ -738,12 +695,6 @@ def run_claude(repo_mode: str | None = None) -> bool: print(f" Session: Active ({repo_mode} mode)") print() - # Add mount arguments - cmd.extend(mount_args) - - # Add image name - cmd.append(ctx.sandbox_image) - # End timing for command build _host_timer.end_phase() # build_docker_cmd @@ -876,12 +827,12 @@ def exec_in_new_container( container_ip = None # Get network configuration based on mode (centralized in helper to prevent divergence) - container_network, gateway_ip, network_extra_args = _get_container_network_config(repo_mode) + net_config = _get_container_network_config(repo_mode) # Choose mount strategy based on repo_mode if repo_mode: # Per-container session mode: allocate IP first for session binding - container_ip = _allocate_container_ip(network=container_network) + container_ip = _allocate_container_ip(network=net_config.network_name) if not container_ip: error("Failed to allocate container IP for session mode") return False @@ -927,82 +878,47 @@ def exec_in_new_container( # Build docker run command # Note: We don't use --rm so we can save logs before cleanup - cmd = [ - "docker", - "run", - "--security-opt", - "label=disable", # Disable SELinux labeling for faster startup - "--name", - container_id, - # Network selection based on mode - "--network", - container_network, - ] - - # If session mode with pre-allocated IP, use static IP assignment - if container_ip: - cmd.extend(["--ip", container_ip]) - - # Add gateway hostname for API access (both modes need this) - cmd.extend( - [ - "--add-host", - f"{ctx.gateway_container_name}:{gateway_ip}", - # Environment variables - "-e", - f"RUNTIME_UID={os.getuid()}", - "-e", - f"RUNTIME_GID={os.getgid()}", - "-e", - f"CONTAINER_ID={container_id}", - "-e", - "PYTHONUNBUFFERED=1", # Force Python to use unbuffered output - "-e", - f"GATEWAY_URL=http://{ctx.gateway_container_name}:{ctx.gateway_port}", - ] - ) - - # Mode-specific network settings (DNS, proxy) from centralized helper - if network_extra_args: - cmd.extend(network_extra_args) - - # If session mode, pass session token for container authentication - if session_token: - cmd.extend(["-e", f"EGG_SESSION_TOKEN={session_token}"]) - - # Add logging configuration for log persistence - log_config = get_docker_log_config(container_id, task_id) - cmd.extend(log_config) + # Caller-specific env vars + caller_env: dict[str, str] = { + "PYTHONUNBUFFERED": "1", + } # Add correlation environment variables for log tracing if task_id: - cmd.extend(["-e", f"EGG_TASK_ID={task_id}"]) + caller_env["EGG_TASK_ID"] = task_id if thread_ts: - cmd.extend(["-e", f"EGG_THREAD_TS={thread_ts}"]) - - # GitHub authentication is handled by the gateway sidecar - # The container does NOT receive GITHUB_TOKEN - all git/gh operations - # route through the gateway which holds the credentials + caller_env["EGG_THREAD_TS"] = thread_ts # Add Anthropic auth configuration based on CLI auth_mode - # Map CLI values to ANTHROPIC_AUTH_METHOD values auth_method_map = {"api-key": "api_key", "oauth-token": "oauth"} anthropic_auth_method = auth_method_map[auth_mode] - cmd.extend(["-e", f"ANTHROPIC_AUTH_METHOD={anthropic_auth_method}"]) + caller_env["ANTHROPIC_AUTH_METHOD"] = anthropic_auth_method # Pass API key when using api-key auth mode if auth_mode == "api-key": api_key = get_anthropic_api_key() if api_key: - cmd.extend(["-e", f"ANTHROPIC_API_KEY={api_key}"]) + caller_env["ANTHROPIC_API_KEY"] = api_key + + # Add logging configuration for log persistence + log_config = get_docker_log_config(container_id, task_id) - # Add mount arguments - cmd.extend(mount_args) + cmd = build_sandbox_docker_cmd( + container_name=container_id, + image=ctx.sandbox_image, + network=net_config, + container_ip=container_ip, + session_token=session_token, + runtime_uid=os.getuid(), + runtime_gid=os.getgid(), + extra_env=caller_env, + extra_args=log_config, + ) - # Add image name - cmd.append(ctx.sandbox_image) + # Insert mount arguments before the image name (last element) + cmd[-1:-1] = mount_args - # Add the command to execute + # Add the command to execute (after image name) cmd.extend(command) # Run container with configurable timeout diff --git a/shared/egg_container/__init__.py b/shared/egg_container/__init__.py new file mode 100644 index 0000000000..db44f2045b --- /dev/null +++ b/shared/egg_container/__init__.py @@ -0,0 +1,152 @@ +"""Shared container-launch command builder. + +Provides ``build_sandbox_docker_cmd()`` — a pure function that constructs the +base ``docker run`` argument list used by production launchers and integration +tests alike. Centralising this logic prevents divergence bugs (missing +``--add-host``, wrong env-var names, absent proxy config, etc.). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Index where lifecycle flags (--rm, -it, -d) should be inserted. +# The returned command always has ["docker", "run", ...], so index 2 is +# immediately after "run". This constant makes the convention explicit +# and allows callers to reference it instead of hardcoding the magic number. +LIFECYCLE_FLAGS_INDEX = 2 + + +@dataclass(frozen=True) +class ContainerNetworkConfig: + """Network parameters needed to wire a sandbox container to its gateway.""" + + network_name: str + gateway_hostname: str + gateway_ip: str + gateway_port: int + repo_mode: str # "private" or "public" + proxy_url: str | None = None # e.g. "http://egg-gateway:3129" + + +def build_sandbox_docker_cmd( + *, + container_name: str, + image: str, + network: ContainerNetworkConfig, + container_ip: str | None = None, + session_token: str | None = None, + runtime_uid: int | None = None, + runtime_gid: int | None = None, + extra_env: dict[str, str] | None = None, + extra_args: list[str] | None = None, +) -> list[str]: + """Build the base ``docker run`` argument list for a sandbox container. + + The returned list starts with ``["docker", "run"]`` and ends with the + *image* name. Callers typically: + + * Insert lifecycle flags (``--rm``, ``-it``) at ``cmd[LIFECYCLE_FLAGS_INDEX:LIFECYCLE_FLAGS_INDEX]``. + Use the module constant to avoid hardcoding the index. + * Insert mount arguments before the image: ``cmd[-1:-1] = mount_args``. + * Append the command to execute after the image name. + + Args: + container_name: Docker ``--name`` value (also used for CONTAINER_ID). + image: Docker image reference (always the last element). + network: Network wiring parameters. + container_ip: Optional static ``--ip`` for session binding. + session_token: If set, passed as ``EGG_SESSION_TOKEN``. + runtime_uid: Host UID forwarded to the container entry-point. + runtime_gid: Host GID forwarded to the container entry-point. + extra_env: Caller-specific environment variables. + extra_args: Caller-specific raw docker arguments. + """ + cmd: list[str] = [ + "docker", + "run", + "--security-opt", + "label=disable", + "--name", + container_name, + "--network", + network.network_name, + ] + + # Static IP for session binding + if container_ip: + cmd.extend(["--ip", container_ip]) + + # Gateway hostname resolution + cmd.extend( + [ + "--add-host", + f"{network.gateway_hostname}:{network.gateway_ip}", + ] + ) + + # --- Environment variables --- + + # Gateway API URL + cmd.extend( + [ + "-e", + f"GATEWAY_URL=http://{network.gateway_hostname}:{network.gateway_port}", + ] + ) + + # Container identity + cmd.extend(["-e", f"CONTAINER_ID={container_name}"]) + + # Runtime UID/GID for entry-point user mapping + if runtime_uid is not None: + cmd.extend(["-e", f"RUNTIME_UID={runtime_uid}"]) + if runtime_gid is not None: + cmd.extend(["-e", f"RUNTIME_GID={runtime_gid}"]) + + # Session token for gateway authentication + if session_token: + cmd.extend(["-e", f"EGG_SESSION_TOKEN={session_token}"]) + + # --- Mode-specific network settings --- + + if network.repo_mode == "private": + proxy = network.proxy_url or f"http://{network.gateway_hostname}:3129" + no_proxy = f"localhost,127.0.0.1,{network.gateway_hostname}" + cmd.extend( + [ + # Disable DNS — fail closed + "--dns", + "0.0.0.0", + "-e", + "PRIVATE_MODE=true", + "-e", + f"HTTP_PROXY={proxy}", + "-e", + f"HTTPS_PROXY={proxy}", + "-e", + f"http_proxy={proxy}", + "-e", + f"https_proxy={proxy}", + "-e", + f"NO_PROXY={no_proxy}", + "-e", + f"no_proxy={no_proxy}", + ] + ) + else: + cmd.extend(["-e", "PRIVATE_MODE=false"]) + + # Caller-specific extras + if extra_env: + for key, value in extra_env.items(): + cmd.extend(["-e", f"{key}={value}"]) + + if extra_args: + cmd.extend(extra_args) + + # Image is always last — callers insert mounts before it, append + # commands after it. + cmd.append(image) + + return cmd diff --git a/shared/pyproject.toml b/shared/pyproject.toml index b74fb8d5a7..acd87dfda4 100644 --- a/shared/pyproject.toml +++ b/shared/pyproject.toml @@ -10,4 +10,4 @@ requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["beads*", "egg_config*", "egg_git*", "egg_logging*", "enrichment*", "git_utils*", "notifications*", "text_utils*"] +include = ["beads*", "egg_config*", "egg_container*", "egg_git*", "egg_logging*", "enrichment*", "git_utils*", "notifications*", "text_utils*"] diff --git a/tests/shared/egg_container/__init__.py b/tests/shared/egg_container/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/shared/egg_container/test_build_cmd.py b/tests/shared/egg_container/test_build_cmd.py new file mode 100644 index 0000000000..f1d4a505e9 --- /dev/null +++ b/tests/shared/egg_container/test_build_cmd.py @@ -0,0 +1,300 @@ +"""Tests for egg_container.build_sandbox_docker_cmd().""" + +from egg_container import ( + LIFECYCLE_FLAGS_INDEX, + ContainerNetworkConfig, + build_sandbox_docker_cmd, +) + + +def _private_config(**overrides): + defaults = { + "network_name": "egg-isolated", + "gateway_hostname": "egg-gateway", + "gateway_ip": "172.32.0.2", + "gateway_port": 9848, + "repo_mode": "private", + "proxy_url": "http://egg-gateway:3129", + } + defaults.update(overrides) + return ContainerNetworkConfig(**defaults) + + +def _public_config(**overrides): + defaults = { + "network_name": "egg-external", + "gateway_hostname": "egg-gateway", + "gateway_ip": "172.33.0.2", + "gateway_port": 9848, + "repo_mode": "public", + } + defaults.update(overrides) + return ContainerNetworkConfig(**defaults) + + +class TestBuildSandboxDockerCmd: + """Core build_sandbox_docker_cmd tests.""" + + def test_basic_structure(self): + """Command starts with 'docker run' and ends with image name.""" + cmd = build_sandbox_docker_cmd( + container_name="test-1", + image="egg-sandbox:latest", + network=_public_config(), + ) + assert cmd[0] == "docker" + assert cmd[1] == "run" + assert cmd[-1] == "egg-sandbox:latest" + + def test_security_opt_present(self): + cmd = build_sandbox_docker_cmd( + container_name="test-1", + image="egg-sandbox:latest", + network=_public_config(), + ) + idx = cmd.index("--security-opt") + assert cmd[idx + 1] == "label=disable" + + def test_container_name(self): + cmd = build_sandbox_docker_cmd( + container_name="my-container", + image="img", + network=_public_config(), + ) + idx = cmd.index("--name") + assert cmd[idx + 1] == "my-container" + + def test_network_set(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(network_name="my-net"), + ) + idx = cmd.index("--network") + assert cmd[idx + 1] == "my-net" + + def test_add_host_present(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(gateway_hostname="gw", gateway_ip="10.0.0.1"), + ) + idx = cmd.index("--add-host") + assert cmd[idx + 1] == "gw:10.0.0.1" + + def test_gateway_url_env(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(gateway_hostname="gw", gateway_port=1234), + ) + assert "GATEWAY_URL=http://gw:1234" in cmd + + def test_container_id_env(self): + cmd = build_sandbox_docker_cmd( + container_name="my-id", + image="img", + network=_public_config(), + ) + assert "CONTAINER_ID=my-id" in cmd + + +class TestOptionalParams: + """Tests for optional parameters.""" + + def test_no_ip_by_default(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + assert "--ip" not in cmd + + def test_ip_when_provided(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + container_ip="172.32.0.50", + ) + idx = cmd.index("--ip") + assert cmd[idx + 1] == "172.32.0.50" + + def test_session_token(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + session_token="tok-123", + ) + assert "EGG_SESSION_TOKEN=tok-123" in cmd + + def test_no_session_token_by_default(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + assert not any("EGG_SESSION_TOKEN" in arg for arg in cmd) + + def test_runtime_uid_gid(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + runtime_uid=1000, + runtime_gid=1001, + ) + assert "RUNTIME_UID=1000" in cmd + assert "RUNTIME_GID=1001" in cmd + + def test_no_uid_gid_by_default(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + assert not any("RUNTIME_UID" in arg for arg in cmd) + assert not any("RUNTIME_GID" in arg for arg in cmd) + + def test_extra_env(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + extra_env={"FOO": "bar", "BAZ": "qux"}, + ) + assert "FOO=bar" in cmd + assert "BAZ=qux" in cmd + + def test_extra_args(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + extra_args=["--log-driver", "json-file"], + ) + assert "--log-driver" in cmd + assert "json-file" in cmd + + +class TestPrivateMode: + """Private mode should set DNS lockdown and proxy env vars.""" + + def test_dns_disabled(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_private_config(), + ) + idx = cmd.index("--dns") + assert cmd[idx + 1] == "0.0.0.0" + + def test_private_mode_env(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_private_config(), + ) + assert "PRIVATE_MODE=true" in cmd + + def test_proxy_env_vars(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_private_config(proxy_url="http://gw:3129"), + ) + assert "HTTP_PROXY=http://gw:3129" in cmd + assert "HTTPS_PROXY=http://gw:3129" in cmd + assert "http_proxy=http://gw:3129" in cmd + assert "https_proxy=http://gw:3129" in cmd + + def test_no_proxy_env_vars(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_private_config(gateway_hostname="gw"), + ) + assert "NO_PROXY=localhost,127.0.0.1,gw" in cmd + assert "no_proxy=localhost,127.0.0.1,gw" in cmd + + def test_proxy_default_when_no_url(self): + """When proxy_url is None, falls back to gateway_hostname:3129.""" + cfg = _private_config(proxy_url=None, gateway_hostname="my-gw") + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=cfg, + ) + assert "HTTP_PROXY=http://my-gw:3129" in cmd + + +class TestPublicMode: + """Public mode should NOT set DNS lockdown or proxy env vars.""" + + def test_public_mode_env(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + assert "PRIVATE_MODE=false" in cmd + + def test_no_dns_lockdown(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + assert "--dns" not in cmd + + def test_no_proxy_vars(self): + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + assert not any("HTTP_PROXY" in arg for arg in cmd) + assert not any("HTTPS_PROXY" in arg for arg in cmd) + + +class TestCallerConventions: + """Test that the output supports the documented caller conventions.""" + + def test_lifecycle_flags_index_constant(self): + """LIFECYCLE_FLAGS_INDEX should be 2 (after 'docker run').""" + assert LIFECYCLE_FLAGS_INDEX == 2 + + def test_lifecycle_flags_insertion(self): + """Callers insert --rm/-it at LIFECYCLE_FLAGS_INDEX.""" + cmd = build_sandbox_docker_cmd( + container_name="c", + image="img", + network=_public_config(), + ) + idx = LIFECYCLE_FLAGS_INDEX + cmd[idx:idx] = ["--rm", "-it"] + assert cmd[:4] == ["docker", "run", "--rm", "-it"] + assert cmd[-1] == "img" + + def test_mount_insertion(self): + """Callers insert mounts at cmd[-1:-1] (before image).""" + cmd = build_sandbox_docker_cmd( + container_name="c", + image="my-image", + network=_public_config(), + ) + cmd[-1:-1] = ["-v", "/host:/container"] + assert cmd[-1] == "my-image" + assert "-v" in cmd + assert "/host:/container" in cmd + + def test_command_append(self): + """Callers append commands after the image.""" + cmd = build_sandbox_docker_cmd( + container_name="c", + image="my-image", + network=_public_config(), + ) + cmd.extend(["claude", "--print", "hello"]) + assert cmd[-3:] == ["claude", "--print", "hello"]