mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes Why this change: Two fail-open paths in the AIO sandbox lifecycle. First, the lark broker capability probe collapsed "provisioner unreachable" into "broker not configured": one 1.5s timeout cached a negative for 300s, so new sandboxes were created without the broker sidecar while the per-user credential directories were still bind-mounted into the container, and existing Pods kept being reused without their actual mode ever being read. Second, an implicit-shell command that timed out or returned an unknown status only marked the current client object; warm-pool reclaim and rediscovery built a fresh client that re-entered a container whose shell state could not be proven clean, and nothing about that ambiguity survived a Gateway restart. What changed: - The capability cache stores confirmed observations only, keyed per provisioner endpoint/auth identity; an unknown answer (timeout, malformed payload) raises instead of selecting credential placement, while a confirmed broker requirement is retained through outages. One in-flight probe per identity shares success and failure, and a Pod-attested mode contradicting the cache forces one shared refresh. - Execution uses the mode attested for the actual sandbox: SandboxInfo.lark_cli_broker flows from the provisioner's atomic Pod observation into the client, so a probe outage delays drift detection but never denies reuse of an attested runtime, and never trusts an unattested one. - The provisioner reports each Pod's actual broker mode, generation (Pod UID) and shell capacity from one read, rejects lark requests conflicting with its configuration (409/503 carrying a capability-refresh marker the Gateway uses to drop the stale cache entry), rejects conflicting or Terminating Pods on coordination creates while reporting Terminating as data to status/get/list, and answers Pod presence independently of Service readiness. - A persistent quarantine (DEER_FLOW_HOME/sandbox-quarantine, keyed by scope/sandbox/generation) fences containers after uncertain implicit shell outcomes across client rebuilds and restarts; in-process reuse, warm reclaim, discovery, create responses and orphan reconciliation all pass one policy gate, and replacement runs under the existing ownership and teardown fences. A fenced sandbox still tracked after a failed release-recycle is destroyed inline on the next acquire. - Quarantine records retire only after confirmed absence, retrying partial cleanup for the quarantined generation and releasing remembered ports under the same fences; local stop failures now propagate instead of being logged away, and remote destroy errors surface to callers. Problem solved: A provisioner blip could silently strip broker isolation from new lark sandboxes for minutes at a time, and ambiguous shell outcomes let corrupted default shells persist across warm reuse, rediscovery and restarts. Docs updated: README, aio_sandbox and skills AGENTS.md. * docs(changelog): record sandbox attestation and quarantine fix (#6436) * fix(sandbox): preserve active leases and fail closed on unattested broker modes Concurrent uploads could recycle a healthy AIO runtime while another holder was creating or cleaning up a command session. Older provisioner responses could also trigger destructive replacement before failing admission. - Distinguish pending session creation from ambiguous outcomes and reserve idle teardown until execution and upload holders finish scope cleanup. - Reject stale fork lookups and reconcile cancelled acquires without releasing a sandbox still borrowed by another holder. - Default Lark broker mode to unverified and preserve unattested Pods until the provisioner is upgraded. - Recognize Docker 29 absence diagnostics while matching resource names exactly. - Preserve Gateway upload errors and cover admission and lifecycle regressions. * fix(sandbox): scope the unverified-broker refuse path to attested providers The fail-closed raise in _lark_cli_env_from_runtime treated every Sandbox without a lark_cli_broker attribute as unverified, but only AioSandbox carries the attestation — E2B, OpenSandbox, Tenki, and Boxlite have no broker sidecar, no credential mounts, and no provisioner to upgrade, so every lark-cli command on those providers failed with an unactionable error. Declare the tri-state on the Sandbox base class (defaulting to False, the only mode brokerless providers have) so the None fail-closed path stays scoped to AioSandbox's deliberate unverified default. - Add lark_cli_broker: bool | None = False to the Sandbox ABC, mirroring the persistent_shell_sessions tri-state pattern - Add a regression test pinning the non-broker overlay for a provider without the attribute * fix(sandbox): close review gaps in broker attestation and teardown lifecycle Address the latest #6436 review round: - The provisioner's post-build broker-mode 409 now carries the capability-refresh marker, so an incompatible-mode conflict surfaced by idempotent re-create, attestation drift or post-create verification drops the Gateway's cached capability observation instead of repeating the same 409 for the full negative-cache TTL. - AioSandboxProvider.destroy() returns the _destroy_tracked outcome: False when teardown is deferred because an execution/upload holder or a peer teardown is still active, so callers can no longer mistake a deferred destroy for a completed one. - A live replacement under the same logical ID proves the fenced old generations are absent; retire_replaced_generations prunes only those stale records so they cannot strand future creates, while the live generation's fence and unversioned crash fences are never touched. - SandboxInfo.from_dict keeps a missing lark_cli_broker attestation unverified (None) instead of silently rehydrating as attested non-broker. - The restricted-network inspect path shares the strict _is_no_such_network_error parser, closing the substring loophole for context-name collisions and wrong-target errors. * docs(sandbox): condense lease notes to keep the AGENTS.md chain under the size gate The three lease notes added in 26c307f3 pushed the effective guidance chain to 98,312 bytes — 8 over the 98,304 hard limit enforced by agent-guidance (AG002). Fold them into two lines carrying the same rules. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
3196 lines
126 KiB
Python
3196 lines
126 KiB
Python
import ast
|
|
import json
|
|
import logging
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.community.aio_sandbox import local_backend as local_backend_module
|
|
from deerflow.community.aio_sandbox.local_backend import (
|
|
LocalContainerBackend,
|
|
_ContainerInspection,
|
|
_docker_server_is_desktop,
|
|
_format_container_command_for_log,
|
|
_format_container_mount,
|
|
_NetworkInspection,
|
|
_redact_container_command_for_log,
|
|
_resolve_docker_bind_host,
|
|
)
|
|
from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo
|
|
from deerflow.utils.network import get_free_port, release_port
|
|
|
|
|
|
def test_sandbox_info_does_not_serialize_or_repr_relay_credentials():
|
|
info = SandboxInfo(
|
|
sandbox_id="sandbox-id",
|
|
sandbox_url="http://localhost:8080",
|
|
request_headers={"X-DeerFlow-Relay-Token": "secret-token"},
|
|
requires_replacement=True,
|
|
)
|
|
|
|
assert "request_headers" not in info.to_dict()
|
|
assert "requires_replacement" not in info.to_dict()
|
|
assert "secret-token" not in repr(info)
|
|
|
|
|
|
def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths():
|
|
args = _format_container_mount("docker", "D:/deer-flow/backend/.deer-flow/threads", "/mnt/threads", False)
|
|
|
|
assert args == [
|
|
"--mount",
|
|
"type=bind,src=D:/deer-flow/backend/.deer-flow/threads,dst=/mnt/threads",
|
|
]
|
|
|
|
|
|
def test_format_container_mount_marks_docker_readonly_mounts():
|
|
args = _format_container_mount("docker", "/host/path", "/mnt/path", True)
|
|
|
|
assert args == [
|
|
"--mount",
|
|
"type=bind,src=/host/path,dst=/mnt/path,readonly",
|
|
]
|
|
|
|
|
|
def test_format_container_mount_keeps_volume_syntax_for_apple_container():
|
|
args = _format_container_mount("container", "/host/path", "/mnt/path", True)
|
|
|
|
assert args == [
|
|
"-v",
|
|
"/host/path:/mnt/path:ro",
|
|
]
|
|
|
|
|
|
def test_redact_container_command_for_log_redacts_env_values():
|
|
redacted = _redact_container_command_for_log(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"-e",
|
|
"API_KEY=secret-value",
|
|
"--env=TOKEN=token-value",
|
|
"--name",
|
|
"sandbox",
|
|
"image",
|
|
]
|
|
)
|
|
|
|
assert "API_KEY=<redacted>" in redacted
|
|
assert "--env=TOKEN=<redacted>" in redacted
|
|
assert "secret-value" not in " ".join(redacted)
|
|
assert "token-value" not in " ".join(redacted)
|
|
|
|
|
|
def test_redact_container_command_for_log_keeps_inherited_env_names():
|
|
redacted = _redact_container_command_for_log(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"-e",
|
|
"API_KEY",
|
|
"--env=TOKEN",
|
|
"--name",
|
|
"sandbox",
|
|
"image",
|
|
]
|
|
)
|
|
|
|
assert redacted == [
|
|
"docker",
|
|
"run",
|
|
"-e",
|
|
"API_KEY",
|
|
"--env=TOKEN",
|
|
"--name",
|
|
"sandbox",
|
|
"image",
|
|
]
|
|
|
|
|
|
def test_format_container_command_for_log_uses_windows_quoting(monkeypatch):
|
|
monkeypatch.setattr(os, "name", "nt")
|
|
|
|
command = _format_container_command_for_log(["docker", "run", "--name", "sandbox one", "image"])
|
|
|
|
assert command == 'docker run --name "sandbox one" image'
|
|
|
|
|
|
def test_start_container_logs_redacted_env_values(monkeypatch, caplog):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={"API_KEY": "secret-value", "NORMAL": "visible-value"},
|
|
)
|
|
monkeypatch.setattr(backend, "_runtime", "docker")
|
|
|
|
captured_cmd: list[str] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_cmd.extend(cmd)
|
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with caplog.at_level(logging.INFO, logger="deerflow.community.aio_sandbox.local_backend"):
|
|
backend._start_container("sandbox-test", 18080)
|
|
|
|
joined_cmd = " ".join(captured_cmd)
|
|
assert "API_KEY=secret-value" in joined_cmd
|
|
assert "NORMAL=visible-value" in joined_cmd
|
|
|
|
log_output = "\n".join(record.getMessage() for record in caplog.records)
|
|
assert "API_KEY=<redacted>" in log_output
|
|
assert "NORMAL=<redacted>" in log_output
|
|
assert "secret-value" not in log_output
|
|
assert "visible-value" not in log_output
|
|
|
|
|
|
def test_restricted_network_requires_docker_engine_28(monkeypatch):
|
|
monkeypatch.setattr(LocalContainerBackend, "_detect_runtime", lambda _self: "docker")
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda *_args, **_kwargs: SimpleNamespace(stdout="27.5.1\n", stderr="", returncode=0),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="Docker Engine 28 or newer"):
|
|
LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
network_config={"mode": "isolated"},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("operating_system", "expected"),
|
|
[
|
|
('"Docker Desktop"', True),
|
|
('"Ubuntu 24.04.3 LTS"', False),
|
|
],
|
|
)
|
|
def test_docker_desktop_detection_uses_daemon_operating_system(monkeypatch, operating_system, expected):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
assert cmd == ["docker", "info", "--format", "{{json .OperatingSystem}}"]
|
|
return SimpleNamespace(stdout=operating_system, stderr="", returncode=0)
|
|
|
|
_docker_server_is_desktop.cache_clear()
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
try:
|
|
assert backend._docker_server_is_desktop() is expected
|
|
finally:
|
|
_docker_server_is_desktop.cache_clear()
|
|
|
|
|
|
def test_docker_desktop_detection_retries_after_transient_failure(monkeypatch):
|
|
"""A transient probe failure is not cached; subsequent call can detect Desktop."""
|
|
attempts = 0
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
return SimpleNamespace(stdout="", stderr="daemon starting", returncode=1)
|
|
return SimpleNamespace(stdout='"Docker Desktop"', stderr="", returncode=0)
|
|
|
|
_docker_server_is_desktop.cache_clear()
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
try:
|
|
assert _docker_server_is_desktop() is False
|
|
assert _docker_server_is_desktop() is True
|
|
assert _docker_server_is_desktop() is True
|
|
assert attempts == 2
|
|
finally:
|
|
_docker_server_is_desktop.cache_clear()
|
|
|
|
|
|
def test_darwin_open_keeps_docker_to_reconcile_restricted_sandbox(monkeypatch):
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
if cmd[:2] == ["docker", "ps"]:
|
|
return SimpleNamespace(stdout="sandbox-transition\n", stderr="", returncode=0)
|
|
if cmd == ["container", "--version"]:
|
|
return SimpleNamespace(stdout="container 0.7.0\n", stderr="", returncode=0)
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.platform.system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
network_config={"mode": "open"},
|
|
)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda _names, **_kwargs: {
|
|
"sandbox-transition": _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "transition",
|
|
"deerflow.network_mode": "allowlist",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset({"deer-flow-sandbox-net-old"}),
|
|
)
|
|
},
|
|
)
|
|
|
|
infos = backend.list_running()
|
|
|
|
assert backend.runtime == "docker"
|
|
assert [info.sandbox_id for info in infos] == ["transition"]
|
|
assert infos[0].requires_replacement is True
|
|
assert ["container", "--version"] in commands
|
|
assert any("label=deerflow.role=sandbox" in command for command in commands)
|
|
|
|
|
|
def test_darwin_open_uses_apple_container_without_managed_docker_sandboxes(monkeypatch):
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
if cmd == ["container", "--version"]:
|
|
return SimpleNamespace(stdout="container 0.7.0\n", stderr="", returncode=0)
|
|
if cmd[:2] == ["docker", "ps"]:
|
|
return SimpleNamespace(stdout="other-sandbox-collision\n", stderr="", returncode=0)
|
|
raise AssertionError(f"unexpected command: {cmd}")
|
|
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.platform.system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
network_config={"mode": "open"},
|
|
)
|
|
|
|
assert backend.runtime == "container"
|
|
assert any("label=deerflow.role=sandbox" in command for command in commands)
|
|
|
|
|
|
def _restricted_backend() -> LocalContainerBackend:
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
backend._runtime = "docker"
|
|
backend._network_mode = "allowlist"
|
|
backend._network_config = {
|
|
"mode": "allowlist",
|
|
"allow_domains": ["pypi.org", "files.pythonhosted.org"],
|
|
"approval": "prompt",
|
|
"temporary_grant_ttl": 300,
|
|
"proxy_image": "proxy:latest",
|
|
}
|
|
return backend
|
|
|
|
|
|
def test_network_policy_digest_is_canonical_and_covers_effective_policy():
|
|
backend = _restricted_backend()
|
|
original = backend._network_policy_digest()
|
|
|
|
backend._network_config["allow_domains"] = ["files.pythonhosted.org", "pypi.org"]
|
|
assert backend._network_policy_digest() == original
|
|
|
|
backend._network_config["allow_domains"] = ["pypi.org"]
|
|
assert backend._network_policy_digest() != original
|
|
|
|
|
|
def test_open_create_labels_sandbox_identity_and_mode(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_start(*_args, **kwargs):
|
|
captured.update(kwargs)
|
|
return "container-id"
|
|
|
|
monkeypatch.setattr(backend, "_start_container", fake_start)
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.get_free_port", lambda start_port=None: 18080)
|
|
|
|
backend.create(thread_id="thread", sandbox_id="labelled-open")
|
|
|
|
assert captured["labels"] == {
|
|
"deerflow.sandbox_id": "labelled-open",
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.network_mode": "open",
|
|
}
|
|
|
|
|
|
def test_create_internal_network_isolates_both_gateway_families_and_labels_policy(monkeypatch):
|
|
backend = _restricted_backend()
|
|
commands: list[list[str]] = []
|
|
monkeypatch.setattr(backend, "_inspect_network", lambda _name: None)
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(stdout="network-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._create_internal_network("sandbox-network", "sandbox-id")
|
|
|
|
create = commands[0]
|
|
assert "com.docker.network.bridge.gateway_mode_ipv4=isolated" in create
|
|
assert "com.docker.network.bridge.gateway_mode_ipv6=isolated" in create
|
|
assert f"deerflow.network_policy_digest={backend._network_policy_digest()}" in create
|
|
|
|
|
|
def test_create_egress_network_is_per_sandbox_and_disables_inter_container_traffic(monkeypatch):
|
|
backend = _restricted_backend()
|
|
commands: list[list[str]] = []
|
|
monkeypatch.setattr(backend, "_inspect_network", lambda _name: None)
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(stdout="network-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._create_egress_network("sandbox-egress", "sandbox-id")
|
|
|
|
create = commands[0]
|
|
assert "--internal" not in create
|
|
assert "com.docker.network.bridge.enable_icc=false" in create
|
|
assert "deerflow.role=egress-network" in create
|
|
assert f"deerflow.network_policy_digest={backend._network_policy_digest()}" in create
|
|
|
|
|
|
def test_restricted_resource_status_requires_matching_policy_image_and_network():
|
|
backend = _restricted_backend()
|
|
sandbox_id = "existing"
|
|
container_name = "sandbox-existing"
|
|
proxy_name, network_name = backend._resource_names(sandbox_id)
|
|
egress_network_name = backend._egress_network_name(sandbox_id)
|
|
inspections = {
|
|
container_name: _ContainerInspection(
|
|
created_at=1.0,
|
|
host_port=None,
|
|
labels=backend._restricted_labels(sandbox_id, "sandbox"),
|
|
image="sandbox:latest",
|
|
networks=frozenset({network_name}),
|
|
),
|
|
proxy_name: _ContainerInspection(
|
|
created_at=1.0,
|
|
host_port=18080,
|
|
labels=backend._restricted_labels(sandbox_id, "network-proxy"),
|
|
image="proxy:latest",
|
|
networks=frozenset({egress_network_name, network_name}),
|
|
relay_token="test-relay-token-that-is-at-least-32-bytes",
|
|
),
|
|
}
|
|
network = _NetworkInspection(
|
|
driver="bridge",
|
|
internal=True,
|
|
labels=backend._restricted_labels(sandbox_id, "network"),
|
|
options={
|
|
"com.docker.network.bridge.gateway_mode_ipv4": "isolated",
|
|
"com.docker.network.bridge.gateway_mode_ipv6": "isolated",
|
|
},
|
|
)
|
|
egress_network = _NetworkInspection(
|
|
driver="bridge",
|
|
internal=False,
|
|
labels=backend._restricted_labels(sandbox_id, "egress-network"),
|
|
options={"com.docker.network.bridge.enable_icc": "false"},
|
|
)
|
|
backend._inspect_network = lambda name: network if name == network_name else egress_network
|
|
|
|
assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "compatible"
|
|
|
|
compatible_proxy = inspections[proxy_name]
|
|
inspections[proxy_name] = _ContainerInspection(
|
|
created_at=compatible_proxy.created_at,
|
|
host_port=compatible_proxy.host_port,
|
|
labels=compatible_proxy.labels,
|
|
image=compatible_proxy.image,
|
|
networks=compatible_proxy.networks,
|
|
)
|
|
assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "mismatch"
|
|
inspections[proxy_name] = compatible_proxy
|
|
|
|
egress_network = _NetworkInspection(
|
|
driver="bridge",
|
|
internal=False,
|
|
labels=backend._restricted_labels(sandbox_id, "egress-network"),
|
|
options={"com.docker.network.bridge.enable_icc": "true"},
|
|
)
|
|
assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "mismatch"
|
|
|
|
egress_network = _NetworkInspection(
|
|
driver="bridge",
|
|
internal=False,
|
|
labels=backend._restricted_labels(sandbox_id, "egress-network"),
|
|
options={"com.docker.network.bridge.enable_icc": "false"},
|
|
)
|
|
inspections[proxy_name] = _ContainerInspection(
|
|
created_at=1.0,
|
|
host_port=18080,
|
|
labels={**backend._restricted_labels(sandbox_id, "network-proxy"), "deerflow.network_policy_digest": "stale"},
|
|
image="proxy:latest",
|
|
networks=frozenset({egress_network_name, network_name}),
|
|
relay_token="test-relay-token-that-is-at-least-32-bytes",
|
|
)
|
|
assert backend._restricted_resources_status(sandbox_id, inspections=inspections) == "mismatch"
|
|
|
|
|
|
def test_restricted_sandbox_has_no_published_port_and_forces_proxy_env(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={"HTTP_PROXY": "http://operator-proxy:3128"},
|
|
)
|
|
monkeypatch.setattr(backend, "_runtime", "docker")
|
|
captured_cmd: list[str] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_cmd.extend(cmd)
|
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._start_container(
|
|
"sandbox-test",
|
|
18080,
|
|
network_override="deer-flow-sandbox-net-test",
|
|
publish_port=False,
|
|
extra_environment={"HTTP_PROXY": "http://deer-flow-netproxy-test:3128"},
|
|
)
|
|
|
|
assert "-p" not in captured_cmd
|
|
assert captured_cmd[captured_cmd.index("--network") + 1] == "deer-flow-sandbox-net-test"
|
|
proxy_values = [captured_cmd[index + 1] for index, value in enumerate(captured_cmd) if value == "-e" and captured_cmd[index + 1].startswith("HTTP_PROXY=")]
|
|
assert proxy_values[-1] == "HTTP_PROXY=http://deer-flow-netproxy-test:3128"
|
|
|
|
|
|
def test_restricted_start_configures_shell_and_aio_browser_proxy(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
backend._network_mode = "allowlist"
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id: "missing")
|
|
monkeypatch.setattr(backend, "_create_internal_network", lambda _name, _sandbox_id: None)
|
|
monkeypatch.setattr(backend, "_create_egress_network", lambda _name, _sandbox_id: None)
|
|
monkeypatch.setattr(backend, "_start_network_proxy", lambda *_args: None)
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_start(*_args, **kwargs):
|
|
captured.update(kwargs)
|
|
return "container-id"
|
|
|
|
monkeypatch.setattr(backend, "_start_container", fake_start)
|
|
|
|
assert (
|
|
backend._start_restricted_sandbox(
|
|
"id",
|
|
"sandbox-id",
|
|
18080,
|
|
None,
|
|
config_mount_exclusion_root=None,
|
|
relay_token="test-relay-token",
|
|
)
|
|
== "container-id"
|
|
)
|
|
|
|
proxy_name, network_name = backend._resource_names("id")
|
|
assert captured["network_override"] == network_name
|
|
assert captured["publish_port"] is False
|
|
environment = captured["extra_environment"]
|
|
assert environment["HTTPS_PROXY"] == f"http://{proxy_name}:3128"
|
|
assert environment["ALL_PROXY"] == f"http://{proxy_name}:3128"
|
|
assert environment["PROXY_SERVER"] == f"{proxy_name}:3128"
|
|
|
|
|
|
def test_restricted_start_refuses_to_remove_resources_with_stale_policy(monkeypatch):
|
|
backend = _restricted_backend()
|
|
cleaned: list[str] = []
|
|
created: list[tuple[str, str]] = []
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id: "mismatch")
|
|
monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append)
|
|
monkeypatch.setattr(backend, "_create_internal_network", lambda name, sandbox_id: created.append((name, sandbox_id)))
|
|
monkeypatch.setattr(backend, "_create_egress_network", lambda name, sandbox_id: created.append((name, sandbox_id)))
|
|
monkeypatch.setattr(backend, "_start_network_proxy", lambda *_args: None)
|
|
monkeypatch.setattr(backend, "_start_container", lambda *_args, **_kwargs: "container-id")
|
|
|
|
with pytest.raises(RuntimeError, match="requires ownership-fenced replacement"):
|
|
backend._start_restricted_sandbox(
|
|
"stale",
|
|
"sandbox-stale",
|
|
18080,
|
|
None,
|
|
config_mount_exclusion_root=None,
|
|
relay_token="test-relay-token",
|
|
)
|
|
|
|
assert cleaned == []
|
|
assert created == []
|
|
|
|
|
|
def test_network_proxy_uses_read_only_root_and_bounded_policy_storage(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
backend._network_mode = "allowlist"
|
|
backend._network_config = {
|
|
"mode": "allowlist",
|
|
"allow_domains": [],
|
|
"approval": "prompt",
|
|
"proxy_image": "proxy:latest",
|
|
}
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(stdout="proxy-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
backend._start_network_proxy(
|
|
"proxy-name",
|
|
"network-name",
|
|
"egress-network-name",
|
|
"sandbox-name",
|
|
18080,
|
|
"sandbox-id",
|
|
"test-relay-token",
|
|
)
|
|
|
|
create = commands[0]
|
|
assert create[create.index("--network") + 1] == "egress-network-name"
|
|
assert "bridge" not in create
|
|
assert "--read-only" in create
|
|
assert create[create.index("--tmpfs") + 1] == "/tmp:rw,noexec,nosuid,size=16m"
|
|
assert "--cap-drop=ALL" in create
|
|
assert "no-new-privileges" in create
|
|
assert "DEERFLOW_RELAY_TOKEN=test-relay-token" in create
|
|
|
|
|
|
def test_start_container_filters_nested_config_mounts_for_policy_scoped_skills(
|
|
monkeypatch,
|
|
):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[
|
|
SimpleNamespace(
|
|
host_path="/host/excluded-skill",
|
|
container_path="/mnt/skills/public/excluded-skill",
|
|
read_only=True,
|
|
),
|
|
SimpleNamespace(
|
|
host_path="/host/unrelated",
|
|
container_path="/mnt/unrelated",
|
|
read_only=True,
|
|
),
|
|
SimpleNamespace(
|
|
host_path="/host/sibling",
|
|
container_path="/mnt/skills-extra",
|
|
read_only=True,
|
|
),
|
|
],
|
|
environment={},
|
|
)
|
|
monkeypatch.setattr(backend, "_runtime", "docker")
|
|
captured_cmd: list[str] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_cmd.extend(cmd)
|
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
backend._start_container(
|
|
"sandbox-test",
|
|
18080,
|
|
extra_mounts=[
|
|
(
|
|
"/host/thread-view/public",
|
|
"/mnt/skills/public",
|
|
True,
|
|
)
|
|
],
|
|
config_mount_exclusion_root="/mnt/skills",
|
|
)
|
|
|
|
command = " ".join(captured_cmd)
|
|
assert "/host/excluded-skill" not in command
|
|
assert "/host/unrelated" in command
|
|
assert "/host/sibling" in command
|
|
assert "/host/thread-view/public" in command
|
|
|
|
|
|
def _capture_start_container_command(monkeypatch, backend: LocalContainerBackend, runtime: str = "docker") -> list[str]:
|
|
monkeypatch.setattr(backend, "_runtime", runtime)
|
|
captured_cmd: list[str] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured_cmd.extend(cmd)
|
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._start_container("sandbox-test", 18080)
|
|
return captured_cmd
|
|
|
|
|
|
def test_resolve_docker_bind_host_defaults_loopback_for_localhost(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_HOST", raising=False)
|
|
|
|
assert _resolve_docker_bind_host() == "127.0.0.1"
|
|
|
|
|
|
def test_resolve_docker_bind_host_follows_host_gateway_mapping_for_dood(monkeypatch):
|
|
"""The bind follows what host.docker.internal actually resolves to."""
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: "192.168.64.1",
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "192.168.64.1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sandbox_host",
|
|
[
|
|
"host.docker.internal",
|
|
"gateway.docker.internal",
|
|
"Host.Docker.Internal.",
|
|
"docker.for.mac.host.internal",
|
|
"docker.for.win.localhost",
|
|
],
|
|
)
|
|
def test_resolve_docker_bind_host_uses_loopback_on_docker_desktop(monkeypatch, sandbox_host):
|
|
"""Docker Desktop cannot bind to internal VM gateway IPs, so default to 127.0.0.1."""
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: "192.168.65.254",
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "127.0.0.1"
|
|
|
|
|
|
def test_resolve_docker_bind_host_preserves_custom_host_on_docker_desktop(monkeypatch):
|
|
"""Custom non-loopback sandbox host on Docker Desktop binds the resolved address."""
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "desktop-box")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: "192.0.2.55",
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "192.0.2.55"
|
|
|
|
|
|
def test_resolve_docker_bind_host_explicit_override_precedes_desktop_detection(monkeypatch):
|
|
"""Explicit DEER_FLOW_SANDBOX_BIND_HOST takes precedence even on Docker Desktop."""
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.0.2.10")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: True,
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "192.0.2.10"
|
|
|
|
|
|
def test_resolve_docker_bind_host_brackets_ipv6_host_gateway(monkeypatch):
|
|
"""An IPv6 host-gateway mapping binds the bracketed IPv6 address."""
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: "[fd00::1]",
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "[fd00::1]"
|
|
|
|
|
|
def test_resolve_docker_bind_host_brackets_bare_ipv6_override(monkeypatch):
|
|
"""A bare IPv6 literal in the override becomes a valid Docker publish host.
|
|
|
|
Docker's ``-p`` syntax requires bracketed IPv6 literals
|
|
(``[fd00::1]:port:8080``); operators writing the escape hatch naturally
|
|
give the bare address, so it must be normalized before use.
|
|
"""
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "fd00::1")
|
|
assert _resolve_docker_bind_host() == "[fd00::1]"
|
|
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "[fd00::1]")
|
|
assert _resolve_docker_bind_host() == "[fd00::1]"
|
|
|
|
# IPv4 literals pass through unchanged.
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.168.64.1")
|
|
assert _resolve_docker_bind_host() == "192.168.64.1"
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "0.0.0.0")
|
|
assert _resolve_docker_bind_host() == "0.0.0.0"
|
|
|
|
|
|
def test_resolve_docker_bind_host_resolves_hostname_override(monkeypatch):
|
|
"""-p requires an IP literal as the host part, so a hostname override
|
|
resolves to the address the daemon actually maps before use."""
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "host.docker.internal")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: "192.168.64.1" if host == "host.docker.internal" else None,
|
|
)
|
|
assert _resolve_docker_bind_host() == "192.168.64.1"
|
|
|
|
|
|
def test_resolve_docker_bind_host_rejects_unresolvable_hostname_override(monkeypatch):
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "not-a-resolvable-host.invalid")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: None,
|
|
)
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_BIND_HOST"):
|
|
_resolve_docker_bind_host()
|
|
|
|
|
|
def test_resolve_docker_bind_host_uses_discovered_bridge_gateway_when_resolution_fails(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_bridge_gateway_ip",
|
|
lambda: "192.168.64.1",
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "192.168.64.1"
|
|
|
|
|
|
def test_resolve_docker_bind_host_falls_back_to_static_bridge_gateway(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
|
lambda: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_bridge_gateway_ip",
|
|
lambda: None,
|
|
)
|
|
|
|
assert _resolve_docker_bind_host() == "172.17.0.1"
|
|
|
|
|
|
def test_resolve_docker_bind_host_uses_ipv6_loopback_for_ipv6_sandbox_host(monkeypatch):
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]")
|
|
|
|
assert _resolve_docker_bind_host() == "[::1]"
|
|
|
|
|
|
def test_resolve_docker_bind_host_logs_selected_bind_reason(caplog):
|
|
with caplog.at_level(logging.DEBUG, logger="deerflow.community.aio_sandbox.local_backend"):
|
|
assert _resolve_docker_bind_host(sandbox_host="localhost", bind_host="") == "127.0.0.1"
|
|
|
|
messages = "\n".join(record.getMessage() for record in caplog.records)
|
|
assert "Docker sandbox bind: 127.0.0.1 (loopback default)" in messages
|
|
|
|
|
|
def test_resolve_docker_bind_host_allows_explicit_override(monkeypatch):
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "localhost")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.0.2.10")
|
|
|
|
assert _resolve_docker_bind_host() == "192.0.2.10"
|
|
|
|
|
|
def test_resolve_docker_bind_host_allows_restoring_legacy_broad_bind(monkeypatch):
|
|
"""DEER_FLOW_SANDBOX_BIND_HOST=0.0.0.0 restores the pre-hardening bind."""
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "0.0.0.0")
|
|
|
|
assert _resolve_docker_bind_host() == "0.0.0.0"
|
|
|
|
|
|
def _clear_hardening_env(monkeypatch):
|
|
for var in (
|
|
"DEER_FLOW_SANDBOX_HOST",
|
|
"DEER_FLOW_SANDBOX_BIND_HOST",
|
|
"DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED",
|
|
"DEER_FLOW_SANDBOX_SECCOMP_PROFILE",
|
|
"DEER_FLOW_SANDBOX_MEMORY",
|
|
"DEER_FLOW_SANDBOX_CPUS",
|
|
"DEER_FLOW_SANDBOX_PIDS_LIMIT",
|
|
"DEER_FLOW_SANDBOX_CONTAINER_USER",
|
|
"DEER_FLOW_SANDBOX_NETWORK",
|
|
"DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
|
def test_start_container_binds_local_docker_port_to_loopback_by_default(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_HOST", raising=False)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "127.0.0.1:18080:8080"
|
|
|
|
|
|
def test_start_container_brackets_bare_ipv6_bind_override(monkeypatch):
|
|
"""A bare IPv6 override reaches -p as a bracketed, valid publish host."""
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "fd00::1")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "[fd00::1]:18080:8080"
|
|
|
|
|
|
def test_start_container_binds_dood_port_to_bridge_gateway(monkeypatch):
|
|
"""Force resolver failure so host DNS cannot bypass the bridge-gateway fallback."""
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
|
lambda host: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"deerflow.community.aio_sandbox.local_backend._docker_bridge_gateway_ip",
|
|
lambda: "172.17.0.1",
|
|
)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "172.17.0.1:18080:8080"
|
|
|
|
|
|
def test_start_container_binds_ipv6_sandbox_host_to_ipv6_loopback(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]")
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "[::1]:18080:8080"
|
|
|
|
|
|
def test_start_container_keeps_apple_container_port_format(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "127.0.0.1")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend, runtime="container")
|
|
|
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "18080:8080"
|
|
|
|
|
|
def test_start_container_hardens_docker_run_by_default(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert "--cap-drop=ALL" in captured_cmd
|
|
# The shipped image's entrypoint starts as root, creates the gem user,
|
|
# chowns /opt/jupyter, prepares /run/user/1000 with chmod, and drops to
|
|
# that user via su. CHOWN/FOWNER/SETUID/SETGID must survive the drop or
|
|
# the container exits before readiness. The root nginx master also writes
|
|
# gem-owned logs under /var/log/nginx for the container's lifetime, which
|
|
# needs DAC_OVERRIDE.
|
|
cap_adds = [arg.split("=", 1)[1] for arg in captured_cmd if arg.startswith("--cap-add=")]
|
|
assert cap_adds == ["CHOWN", "FOWNER", "SETUID", "SETGID", "DAC_OVERRIDE"]
|
|
security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"]
|
|
assert "no-new-privileges" in security_opts
|
|
# The shipped AIO image needs seccomp=unconfined for its Chromium
|
|
# browser (upstream FAQ), so that option stays the default; the
|
|
# hardening that does not break the shipped image is kept.
|
|
assert "seccomp=unconfined" in security_opts
|
|
assert captured_cmd[captured_cmd.index("--memory") + 1] == "2g"
|
|
assert captured_cmd[captured_cmd.index("--cpus") + 1] == "2"
|
|
assert captured_cmd[captured_cmd.index("--pids-limit") + 1] == "512"
|
|
# Opt-in-only knobs stay absent unless explicitly configured.
|
|
assert "--user" not in captured_cmd
|
|
assert "--network" not in captured_cmd
|
|
|
|
|
|
def test_start_container_seccomp_can_opt_out_to_default_profile(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED", "0")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"]
|
|
assert "seccomp=unconfined" not in security_opts
|
|
# The opt-out must select the built-in profile explicitly: omitting the
|
|
# option would inherit the daemon's (possibly unconfined) default.
|
|
assert "seccomp=builtin" in security_opts
|
|
assert "no-new-privileges" in security_opts
|
|
|
|
|
|
def test_start_container_seccomp_profile_env_selects_custom_profile(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_SECCOMP_PROFILE", "/etc/docker/chromium-seccomp.json")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"]
|
|
assert "seccomp=/etc/docker/chromium-seccomp.json" in security_opts
|
|
assert "seccomp=unconfined" not in security_opts
|
|
assert "no-new-privileges" in security_opts
|
|
|
|
|
|
def test_resolve_sandbox_host_address_formats_and_filters(monkeypatch):
|
|
import socket as socket_module
|
|
|
|
def fake_getaddrinfo(host, port):
|
|
if host == "v4host":
|
|
return [(socket_module.AF_INET, None, None, "", ("203.0.113.7", 0))]
|
|
if host == "v6host":
|
|
return [(socket_module.AF_INET6, None, None, "", ("fd00::1%eth0", 0, 0, 0))]
|
|
if host == "wildcard":
|
|
return [(socket_module.AF_INET, None, None, "", ("0.0.0.0", 0))]
|
|
raise OSError("no such host")
|
|
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.socket.getaddrinfo", fake_getaddrinfo)
|
|
|
|
from deerflow.community.aio_sandbox.local_backend import _resolve_sandbox_host_address
|
|
|
|
assert _resolve_sandbox_host_address("v4host") == "203.0.113.7"
|
|
# zone ids are stripped and IPv6 is bracketed for docker -p syntax
|
|
assert _resolve_sandbox_host_address("v6host") == "[fd00::1]"
|
|
# wildcard resolutions are not bindable choices
|
|
assert _resolve_sandbox_host_address("wildcard") is None
|
|
assert _resolve_sandbox_host_address("unknown.invalid") is None
|
|
|
|
|
|
def test_start_container_resource_limits_env_override(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_MEMORY", "4g")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_CPUS", "4")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_PIDS_LIMIT", "1024")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("--memory") + 1] == "4g"
|
|
assert captured_cmd[captured_cmd.index("--cpus") + 1] == "4"
|
|
assert captured_cmd[captured_cmd.index("--pids-limit") + 1] == "1024"
|
|
|
|
|
|
def test_start_container_resource_limits_can_be_disabled(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_MEMORY", "0")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_CPUS", "none")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_PIDS_LIMIT", "0")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert "--memory" not in captured_cmd
|
|
assert "--cpus" not in captured_cmd
|
|
assert "--pids-limit" not in captured_cmd
|
|
|
|
|
|
def test_start_container_passes_through_user_and_network(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_CONTAINER_USER", "1000:1000")
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "deer-flow-sandbox-egress")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("--user") + 1] == "1000:1000"
|
|
assert captured_cmd[captured_cmd.index("--network") + 1] == "deer-flow-sandbox-egress"
|
|
|
|
|
|
def test_start_container_rejects_host_networking(monkeypatch):
|
|
"""host mode discards -p/--publish, voiding the hardened bind and
|
|
re-exposing the unauthenticated exec API on the host's interfaces."""
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "host")
|
|
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_rejects_shared_container_network_namespace(monkeypatch):
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "container:gateway")
|
|
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_rejects_none_network(monkeypatch):
|
|
"""The none driver leaves the container loopback-only, so the published
|
|
sandbox API port cannot receive traffic: readiness would time out and
|
|
every acquisition would fail. Fail fast at start-up instead."""
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "none")
|
|
|
|
with pytest.raises(RuntimeError, match="loopback-only"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_does_not_add_docker_hardening_to_apple_container(monkeypatch):
|
|
"""Apple Container's CLI does not support the Docker hardening flags."""
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "127.0.0.1")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend, runtime="container")
|
|
|
|
assert "--cap-drop=ALL" not in captured_cmd
|
|
assert "--security-opt" not in captured_cmd
|
|
assert "--memory" not in captured_cmd
|
|
assert "--cpus" not in captured_cmd
|
|
assert "--pids-limit" not in captured_cmd
|
|
|
|
|
|
def _backend_for_inspect_tests() -> LocalContainerBackend:
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
backend._runtime = "docker"
|
|
return backend
|
|
|
|
|
|
def test_is_container_running_false_when_container_missing(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Error: No such object: sandbox-missing", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend._is_container_running("sandbox-missing") is False
|
|
|
|
|
|
def test_is_container_running_raises_on_runtime_error(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Cannot connect to the Docker daemon", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(RuntimeError, match="Failed to inspect container sandbox-busy"):
|
|
backend._is_container_running("sandbox-busy")
|
|
|
|
|
|
def test_is_container_running_raises_on_timeout(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(RuntimeError, match="Timed out checking container sandbox-timeout"):
|
|
backend._is_container_running("sandbox-timeout")
|
|
|
|
|
|
def test_discover_returns_none_when_runtime_check_fails(monkeypatch):
|
|
"""A transient daemon error during discovery must fall through to create, not fail acquire."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Cannot connect to the Docker daemon", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend.discover("sandbox-blip") is None
|
|
|
|
|
|
def test_discover_returns_none_when_runtime_check_times_out(monkeypatch):
|
|
"""An inspect timeout during discovery must not propagate out of discover()."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend.discover("sandbox-timeout") is None
|
|
|
|
|
|
def test_discover_replaces_container_with_insufficient_shell_capacity(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._environment["MAX_SHELL_SESSIONS"] = "13"
|
|
container_name = "sandbox-existing"
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
created_at=1.0,
|
|
host_port=18080,
|
|
labels={
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "existing",
|
|
"deerflow.network_mode": "open",
|
|
},
|
|
image="sandbox:latest",
|
|
networks=frozenset({"bridge"}),
|
|
max_shell_sessions=10,
|
|
)
|
|
},
|
|
)
|
|
|
|
info = backend.discover("existing")
|
|
|
|
assert info is not None
|
|
assert info.requires_replacement is True
|
|
assert info.sandbox_url == ""
|
|
|
|
|
|
def test_list_running_marks_insufficient_shell_capacity_for_fenced_replacement(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._environment["MAX_SHELL_SESSIONS"] = "13"
|
|
container_name = "sandbox-existing"
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
assert cmd[:2] == ["docker", "ps"]
|
|
return SimpleNamespace(stdout=f"{container_name}\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
created_at=1.0,
|
|
host_port=18080,
|
|
labels={
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "existing",
|
|
"deerflow.network_mode": "open",
|
|
},
|
|
image="sandbox:latest",
|
|
networks=frozenset({"bridge"}),
|
|
max_shell_sessions=10,
|
|
)
|
|
},
|
|
)
|
|
|
|
infos = backend.list_running()
|
|
|
|
assert len(infos) == 1
|
|
assert infos[0].requires_replacement is True
|
|
assert infos[0].sandbox_url == ""
|
|
|
|
|
|
def test_restricted_discovery_uses_proxy_relay_port(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
container_name = "sandbox-existing"
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
inspections = {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "existing",
|
|
"deerflow.network_mode": "allowlist",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset(),
|
|
),
|
|
proxy_name: _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{},
|
|
"proxy:latest",
|
|
frozenset(),
|
|
"test-relay-token-that-is-at-least-32-bytes",
|
|
),
|
|
}
|
|
monkeypatch.setattr(backend, "_batch_inspect", lambda *_args, **_kwargs: inspections)
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "compatible")
|
|
readiness: list[dict[str, object]] = []
|
|
|
|
def fake_ready(_url, **kwargs):
|
|
readiness.append(kwargs)
|
|
return True
|
|
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.wait_for_sandbox_ready", fake_ready)
|
|
|
|
info = backend.discover("existing")
|
|
|
|
assert info is not None
|
|
assert info.container_name == "sandbox-existing"
|
|
assert info.sandbox_url == "http://localhost:18080"
|
|
assert info.request_headers == {"X-DeerFlow-Relay-Token": "test-relay-token-that-is-at-least-32-bytes"}
|
|
assert readiness == [{"timeout": 5, "headers": info.request_headers}]
|
|
|
|
|
|
def test_restricted_discovery_reports_stale_policy_without_removing_resources(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
cleaned: list[str] = []
|
|
container_name = "sandbox-stale"
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "stale",
|
|
"deerflow.network_mode": "allowlist",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset(),
|
|
)
|
|
},
|
|
)
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "mismatch")
|
|
monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append)
|
|
|
|
info = backend.discover("stale")
|
|
|
|
assert info is not None
|
|
assert info.sandbox_id == "stale"
|
|
assert info.container_name == "sandbox-stale"
|
|
assert info.requires_replacement is True
|
|
assert cleaned == []
|
|
|
|
|
|
def test_restricted_discovery_reports_legacy_open_sandbox_for_fenced_replacement(monkeypatch):
|
|
backend = _restricted_backend()
|
|
container_name = "sandbox-legacy-open"
|
|
inspected_batches: list[list[str]] = []
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
|
|
def fake_batch_inspect(names, **_kwargs):
|
|
inspected_batches.append(list(names))
|
|
return {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{},
|
|
"sandbox:latest",
|
|
frozenset({"bridge"}),
|
|
)
|
|
}
|
|
|
|
monkeypatch.setattr(backend, "_batch_inspect", fake_batch_inspect)
|
|
|
|
info = backend.discover("legacy-open")
|
|
|
|
assert info is not None
|
|
assert info.requires_replacement is True
|
|
assert info.sandbox_url == ""
|
|
assert inspected_batches == [[container_name]]
|
|
|
|
|
|
def test_restricted_discovery_reports_labelled_open_sandbox_for_fenced_replacement(monkeypatch):
|
|
backend = _restricted_backend()
|
|
container_name = "sandbox-labelled-open"
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "labelled-open",
|
|
"deerflow.network_mode": "open",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset({"bridge"}),
|
|
)
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_restricted_resources_status",
|
|
lambda *_args, **_kwargs: pytest.fail("a mode mismatch must be reported before restricted resource inspection"),
|
|
)
|
|
|
|
info = backend.discover("labelled-open")
|
|
|
|
assert info is not None
|
|
assert info.requires_replacement is True
|
|
assert info.sandbox_url == ""
|
|
|
|
|
|
def test_open_discovery_reports_restricted_sandbox_for_fenced_replacement(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
container_name = "sandbox-old-restricted"
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "old-restricted",
|
|
"deerflow.network_mode": "allowlist",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset({"deer-flow-sandbox-net-old"}),
|
|
)
|
|
},
|
|
)
|
|
|
|
info = backend.discover("old-restricted")
|
|
|
|
assert info is not None
|
|
assert info.requires_replacement is True
|
|
assert info.sandbox_url == ""
|
|
|
|
|
|
def test_restricted_discovery_leaves_unlabelled_name_collision_unmanaged(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
container_name = "sandbox-foreign"
|
|
cleaned: list[str] = []
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{},
|
|
"foreign:latest",
|
|
frozenset(),
|
|
)
|
|
},
|
|
)
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "mismatch")
|
|
monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append)
|
|
|
|
assert backend.discover("foreign") is None
|
|
assert cleaned == []
|
|
|
|
|
|
def test_restricted_discovery_leaves_unlabelled_published_foreign_image_unmanaged(monkeypatch):
|
|
backend = _restricted_backend()
|
|
container_name = "sandbox-foreign-published"
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
container_name: _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{},
|
|
"foreign:latest",
|
|
frozenset({"bridge"}),
|
|
)
|
|
},
|
|
)
|
|
|
|
assert backend.discover("foreign-published") is None
|
|
|
|
|
|
def test_restricted_health_rejects_resources_with_stale_policy(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda _name: True)
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id: "mismatch")
|
|
|
|
assert not backend.is_alive(
|
|
SandboxInfo(
|
|
sandbox_id="stale",
|
|
sandbox_url="http://localhost:18080",
|
|
container_name="sandbox-stale",
|
|
)
|
|
)
|
|
|
|
|
|
def test_restricted_list_reconciliation_reports_stale_policy_without_removing_resources(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
proxy_name, _ = backend._resource_names("stale")
|
|
cleaned: list[str] = []
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda *_args, **_kwargs: SimpleNamespace(stdout="sandbox-stale\n", stderr="", returncode=0),
|
|
)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda _names, **_kwargs: {
|
|
"sandbox-stale": _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "stale",
|
|
"deerflow.network_mode": "allowlist",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset(),
|
|
),
|
|
proxy_name: _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{"deerflow.role": "network-proxy", "deerflow.sandbox_id": "stale"},
|
|
"proxy:latest",
|
|
frozenset(),
|
|
),
|
|
},
|
|
)
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", lambda _sandbox_id, **_kwargs: "mismatch")
|
|
monkeypatch.setattr(backend, "_cleanup_restricted_resources", cleaned.append)
|
|
|
|
infos = backend.list_running()
|
|
|
|
assert len(infos) == 1
|
|
assert infos[0].sandbox_id == "stale"
|
|
assert infos[0].requires_replacement is True
|
|
assert cleaned == []
|
|
|
|
|
|
def test_restricted_list_reports_legacy_open_sandbox_for_fenced_replacement(monkeypatch):
|
|
backend = _restricted_backend()
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(stdout="sandbox-legacy-open\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda _names, **_kwargs: {
|
|
"sandbox-legacy-open": _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{},
|
|
"sandbox:latest",
|
|
frozenset({"bridge"}),
|
|
)
|
|
},
|
|
)
|
|
|
|
infos = backend.list_running()
|
|
|
|
assert len(infos) == 1
|
|
assert infos[0].requires_replacement is True
|
|
assert infos[0].sandbox_url == ""
|
|
assert "label=deerflow.role=sandbox" not in commands[0]
|
|
|
|
|
|
def test_open_list_reports_restricted_sandbox_for_fenced_replacement(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda *_args, **_kwargs: SimpleNamespace(stdout="sandbox-old-restricted\n", stderr="", returncode=0),
|
|
)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda _names, **_kwargs: {
|
|
"sandbox-old-restricted": _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": "old-restricted",
|
|
"deerflow.network_mode": "isolated",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset({"deer-flow-sandbox-net-old"}),
|
|
)
|
|
},
|
|
)
|
|
|
|
infos = backend.list_running()
|
|
|
|
assert len(infos) == 1
|
|
assert infos[0].requires_replacement is True
|
|
assert infos[0].sandbox_url == ""
|
|
|
|
|
|
def test_restricted_list_running_excludes_sidecars_for_overlapping_custom_prefix(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
backend._container_prefix = "deer-flow"
|
|
sandbox_id = "live"
|
|
sandbox_name = "deer-flow-live"
|
|
proxy_name, _ = backend._resource_names(sandbox_id)
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(
|
|
stdout=f"{sandbox_name}\n{proxy_name}\n",
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
inspected_batches: list[list[str]] = []
|
|
|
|
def fake_batch_inspect(names, **_kwargs):
|
|
inspected_batches.append(list(names))
|
|
return {
|
|
sandbox_name: _ContainerInspection(
|
|
1.0,
|
|
None,
|
|
{
|
|
"deerflow.role": "sandbox",
|
|
"deerflow.sandbox_id": sandbox_id,
|
|
"deerflow.network_mode": "allowlist",
|
|
},
|
|
"sandbox:latest",
|
|
frozenset(),
|
|
),
|
|
proxy_name: _ContainerInspection(
|
|
1.0,
|
|
18080,
|
|
{"deerflow.role": "network-proxy", "deerflow.sandbox_id": sandbox_id},
|
|
"proxy:latest",
|
|
frozenset(),
|
|
"test-relay-token-that-is-at-least-32-bytes",
|
|
),
|
|
}
|
|
|
|
monkeypatch.setattr(backend, "_batch_inspect", fake_batch_inspect)
|
|
checked: list[str] = []
|
|
|
|
def compatible(current_sandbox_id, **_kwargs):
|
|
checked.append(current_sandbox_id)
|
|
return "compatible"
|
|
|
|
monkeypatch.setattr(backend, "_restricted_resources_status", compatible)
|
|
|
|
infos = backend.list_running()
|
|
|
|
assert [info.sandbox_id for info in infos] == [sandbox_id]
|
|
assert checked == [sandbox_id]
|
|
assert "label=deerflow.role=sandbox" not in commands[0]
|
|
sidecar_as_sandbox_id = proxy_name[len(backend._container_prefix) + 1 :]
|
|
fabricated_proxy_name, _ = backend._resource_names(sidecar_as_sandbox_id)
|
|
assert fabricated_proxy_name not in {name for batch in inspected_batches for name in batch}
|
|
|
|
|
|
def test_restricted_destroy_stops_pair_and_removes_both_networks(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "isolated"
|
|
proxy_name, network_name = backend._resource_names("existing")
|
|
egress_network_name = backend._egress_network_name("existing")
|
|
stopped: list[str] = []
|
|
commands: list[list[str]] = []
|
|
monkeypatch.setattr(backend, "_stop_container", stopped.append)
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend.destroy(
|
|
SandboxInfo(
|
|
sandbox_id="existing",
|
|
sandbox_url="http://localhost:18080",
|
|
container_name="sandbox-existing",
|
|
container_id="sandbox-container-id",
|
|
)
|
|
)
|
|
|
|
assert stopped == [proxy_name, "sandbox-container-id"]
|
|
assert ["docker", "rm", "-f", proxy_name] in commands
|
|
assert ["docker", "network", "rm", network_name] in commands
|
|
assert ["docker", "network", "rm", egress_network_name] in commands
|
|
|
|
|
|
def test_open_mode_replacement_destroy_removes_restricted_sidecar_and_networks(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
stopped: list[str] = []
|
|
cleaned: list[tuple[str, bool, bool]] = []
|
|
monkeypatch.setattr(backend, "_stop_container", stopped.append)
|
|
monkeypatch.setattr(backend, "_remove_restricted_proxy", lambda sandbox_id: cleaned.append((sandbox_id, False, True)))
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_cleanup_restricted_resources",
|
|
lambda sandbox_id, *, stop_sandbox=True, remove_proxy=True: cleaned.append((sandbox_id, stop_sandbox, remove_proxy)),
|
|
)
|
|
|
|
backend.destroy(
|
|
SandboxInfo(
|
|
sandbox_id="old-restricted",
|
|
sandbox_url="",
|
|
container_name="sandbox-old-restricted",
|
|
requires_replacement=True,
|
|
)
|
|
)
|
|
|
|
assert stopped == ["sandbox-old-restricted"]
|
|
assert cleaned == [("old-restricted", False, True), ("old-restricted", False, False)]
|
|
|
|
|
|
def test_deny_pending_network_policy_events_uses_atomic_proxy_command(monkeypatch):
|
|
backend = _restricted_backend()
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **_kwargs):
|
|
commands.append(cmd)
|
|
return SimpleNamespace(stdout="17\n", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend.deny_pending_network_policy_events("existing") is True
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
assert commands == [["docker", "exec", proxy_name, "python", "/tmp/deerflow-network-proxy.py", "deny-pending"]]
|
|
|
|
|
|
def test_is_container_running_false_on_apple_container_not_found(monkeypatch):
|
|
"""Apple Container's generic "not found" is trusted when it names the container."""
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = "container"
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr='Error: not found: "sandbox-apple"', returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert backend._is_container_running("sandbox-apple") is False
|
|
|
|
|
|
def test_is_container_running_raises_on_unrelated_not_found_error(monkeypatch):
|
|
"""Transient errors whose text contains "not found" must not be misread as a dead container."""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="", stderr="Error: credential helper not found in $PATH", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(RuntimeError, match="Failed to inspect container sandbox-busy"):
|
|
backend._is_container_running("sandbox-busy")
|
|
|
|
|
|
@pytest.mark.parametrize("runtime", ["docker", "container"])
|
|
def test_absence_does_not_treat_stopped_container_as_missing(monkeypatch, runtime):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = runtime
|
|
name = "sandbox-stopped"
|
|
entry = {"Name": f"/{name}", "Id": "stopped-generation", "State": {"Running": False}} if runtime == "docker" else {"configuration": {"id": name}, "status": "stopped"}
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0))
|
|
assert backend.is_absent("stopped") is False
|
|
|
|
|
|
@pytest.mark.parametrize("runtime", ["docker", "container"])
|
|
def test_absence_requires_runtime_confirmation(monkeypatch, runtime):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = runtime
|
|
name = f"{backend._container_prefix}-missing"
|
|
error = f"Error: No such container: {name}" if runtime == "docker" else f'Error: not found: "{name}"'
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout="", stderr=error, returncode=1))
|
|
assert backend.is_absent("missing") is True
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout="", stderr="daemon unavailable", returncode=1))
|
|
with pytest.raises(RuntimeError):
|
|
backend.is_absent("missing")
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["is_absent", "_is_container_running", "_stop_container"])
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
'Failed to initialize: unable to resolve docker endpoint: context "deerflow-container-review-missing": context not found: open /synthetic-home/.docker/contexts/meta/context/meta.json: no such file or directory',
|
|
'unable to resolve docker endpoint: context "sandbox-missing": context not found',
|
|
"Error: container runtime not found while inspecting sandbox-missing",
|
|
"OCI runtime failed for container sandbox-missing: executable file not found in $PATH",
|
|
"Error: No such object: sandbox-other",
|
|
"Error response from daemon: No such container: sandbox-missing-extra",
|
|
# Docker 29 lowercases the client-side scaffolding; the near-miss and
|
|
# case-sensitivity rules must survive the normalization.
|
|
"error: no such object: sandbox-other",
|
|
"error response from daemon: no such container: sandbox-missing-extra",
|
|
"error: no such object: Sandbox-Missing",
|
|
"error: no such object",
|
|
"error: no such image: sandbox-missing",
|
|
"prefix error: no such object: sandbox-missing",
|
|
"error: no such object: sandbox-missing suffix",
|
|
"error: no such object: sandbox-missing\nError response from daemon: permission denied",
|
|
"warning: using default context\nerror: no such object: sandbox-missing",
|
|
],
|
|
)
|
|
def test_runtime_absence_rejects_errors_not_about_the_requested_container(monkeypatch, operation, error):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def failed(cmd, **kwargs):
|
|
if kwargs.get("check"):
|
|
raise subprocess.CalledProcessError(1, cmd, stderr=error)
|
|
return SimpleNamespace(stdout="", stderr=error, returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", failed)
|
|
target = "missing" if operation == "is_absent" else "sandbox-missing"
|
|
with pytest.raises((RuntimeError, subprocess.CalledProcessError)):
|
|
getattr(backend, operation)(target)
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["is_absent", "_is_container_running", "_stop_container"])
|
|
@pytest.mark.parametrize(
|
|
("runtime", "error"),
|
|
[
|
|
("docker", "Error: No such object: sandbox-missing"),
|
|
("docker", "Error response from daemon: No such container: sandbox-missing"),
|
|
# Docker CLI v29 lowercases the client-side error scaffolding
|
|
# ("error: no such object: <name>"); the name stays byte-exact.
|
|
("docker", "error: no such object: sandbox-missing"),
|
|
("docker", "error response from daemon: no such container: sandbox-missing"),
|
|
# ArgumentParser prefers ContainerizationError.errorDescription, so
|
|
# ContainerInspect's missing-resource message has no type or quotes.
|
|
("container", "Error: container not found: sandbox-missing"),
|
|
("container", 'Error: notFound: "container not found: sandbox-missing"'),
|
|
("container", 'notFound: "container not found: sandbox-missing"'),
|
|
("container", 'Error: not found: "sandbox-missing"'),
|
|
],
|
|
)
|
|
def test_runtime_absence_accepts_exact_missing_container_responses(monkeypatch, operation, runtime, error):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = runtime
|
|
|
|
def missing(cmd, **kwargs):
|
|
if kwargs.get("check"):
|
|
raise subprocess.CalledProcessError(1, cmd, stderr=error)
|
|
return SimpleNamespace(stdout="[]", stderr=error, returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", missing)
|
|
target = "missing" if operation == "is_absent" else "sandbox-missing"
|
|
result = getattr(backend, operation)(target)
|
|
assert result is (True if operation == "is_absent" else False if operation == "_is_container_running" else None)
|
|
|
|
|
|
@pytest.mark.parametrize("state", ["created", "exited", "running", "dead"])
|
|
def test_inspect_runtime_observes_docker_generation_without_health_or_port(monkeypatch, state):
|
|
backend = _backend_for_inspect_tests()
|
|
commands = []
|
|
entry = {"Id": "immutable-docker-generation", "Name": "/sandbox-existing", "Created": "2026-04-08T01:22:50Z", "State": {"Status": state, "Running": state == "running"}}
|
|
|
|
def inspect(cmd, **kwargs):
|
|
commands.append(cmd)
|
|
assert 0 < kwargs["timeout"] <= 15
|
|
return SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", inspect)
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda *_args: pytest.fail("runtime inspection must not require running"))
|
|
monkeypatch.setattr(backend, "_get_container_port", lambda *_args: pytest.fail("runtime inspection must not require a port"))
|
|
monkeypatch.setattr(local_backend_module, "wait_for_sandbox_ready", lambda *_args, **_kwargs: pytest.fail("runtime inspection must not require health"))
|
|
|
|
info = backend.inspect_runtime("existing")
|
|
|
|
assert info is not None
|
|
assert info.sandbox_id == "existing"
|
|
assert info.container_name == "sandbox-existing"
|
|
assert info.container_id == "immutable-docker-generation"
|
|
assert info.created_at == 1775611370.0
|
|
assert commands == [["docker", "inspect", "sandbox-existing"]]
|
|
|
|
|
|
def test_inspect_runtime_keeps_apple_resource_with_unknown_generation(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = "container"
|
|
# ManagedContainer serializes configuration.id, which is the user-selected
|
|
# logical name rather than an immutable generation; it cannot fence teardown.
|
|
entry = {"id": "sandbox-existing", "configuration": {"id": "sandbox-existing"}, "status": {"state": "stopped"}}
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0))
|
|
|
|
info = backend.inspect_runtime("existing")
|
|
|
|
assert info is not None
|
|
assert info.container_name == "sandbox-existing"
|
|
assert info.container_id is None
|
|
assert backend.is_absent("existing") is False
|
|
|
|
|
|
@pytest.mark.parametrize("stopped", [False, True])
|
|
def test_destroy_retry_releases_port_recovered_from_runtime_metadata(monkeypatch, stopped):
|
|
from deerflow.utils.network import PortAllocator
|
|
|
|
backend = _backend_for_inspect_tests()
|
|
allocator = PortAllocator()
|
|
monkeypatch.setattr(allocator, "_is_port_available", lambda port: port not in allocator._reserved_ports)
|
|
port = allocator.allocate(18080, max_range=1)
|
|
monkeypatch.setattr(local_backend_module, "release_port", allocator.release)
|
|
entry = {
|
|
"Id": "retained-generation",
|
|
"Name": "/sandbox-existing",
|
|
"NetworkSettings": {"Ports": {"8080/tcp": None if stopped else [{"HostPort": str(port)}]}},
|
|
"HostConfig": {"PortBindings": {"8080/tcp": [{"HostPort": str(port)}]}},
|
|
}
|
|
failed = True
|
|
|
|
def runtime(cmd, **kwargs):
|
|
nonlocal failed
|
|
if "inspect" in cmd:
|
|
return SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0)
|
|
if failed:
|
|
failed = False
|
|
raise subprocess.CalledProcessError(1, cmd, stderr="daemon denied stop")
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
original = SandboxInfo("existing", f"http://localhost:{port}", container_id="retained-generation")
|
|
with pytest.raises(subprocess.CalledProcessError):
|
|
backend.destroy(original)
|
|
with pytest.raises(RuntimeError, match="No available port"):
|
|
allocator.allocate(18080, max_range=1)
|
|
|
|
recovered = backend.inspect_runtime("existing")
|
|
assert recovered is not None
|
|
backend.destroy(recovered)
|
|
assert allocator.allocate(18080, max_range=1) == port
|
|
|
|
|
|
@pytest.mark.parametrize("proxy_missing", [False, True])
|
|
@pytest.mark.parametrize("persisted_mode", ["allowlist", "isolated"])
|
|
def test_inspect_runtime_recovers_restricted_proxy_port_without_losing_main_identity(monkeypatch, proxy_missing, persisted_mode):
|
|
backend = _backend_for_inspect_tests()
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
entry = {"Id": "main-generation", "Name": "/sandbox-existing", "Config": {"Labels": {"deerflow.network_mode": persisted_mode}}}
|
|
proxy = {"Id": "proxy-generation", "Name": f"/{proxy_name}", "HostConfig": {"PortBindings": {"8080/tcp": [{"HostPort": "18080"}]}}}
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if cmd[-1] == proxy_name:
|
|
return SimpleNamespace(stdout="[]" if proxy_missing else json.dumps([proxy]), stderr=f"Error: No such object: {proxy_name}" if proxy_missing else "", returncode=1 if proxy_missing else 0)
|
|
return SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
info = backend.inspect_runtime("existing")
|
|
assert info is not None
|
|
assert info.container_id == "main-generation"
|
|
assert info.sandbox_url == ("" if proxy_missing else "http://localhost:18080")
|
|
|
|
|
|
def test_inspect_runtime_propagates_unknown_proxy_port_probe(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
entry = {"Id": "main-generation", "Name": "/sandbox-existing", "Config": {"Labels": {"deerflow.network_mode": "allowlist"}}}
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if cmd[-1] == proxy_name:
|
|
return SimpleNamespace(stdout="", stderr="daemon unavailable", returncode=1)
|
|
return SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
with pytest.raises(RuntimeError, match="daemon unavailable"):
|
|
backend.inspect_runtime("existing")
|
|
|
|
|
|
def test_inspect_runtime_retains_direct_binding_during_network_mode_transition(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
entry = {"Id": "legacy-generation", "Name": "/sandbox-existing", "HostConfig": {"PortBindings": {"8080/tcp": [{"HostPort": "18080"}]}}}
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if cmd[-1] == proxy_name:
|
|
return SimpleNamespace(stdout="[]", stderr=f"Error: No such object: {proxy_name}", returncode=1)
|
|
return SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
assert backend.inspect_runtime("existing").sandbox_url == "http://localhost:18080"
|
|
|
|
|
|
def test_inspect_runtime_does_not_inherit_another_generations_pending_port(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._pending_cleanup_ports["old-generation"] = ("existing", 18080)
|
|
entry = {"Id": "replacement-generation", "Name": "/sandbox-existing"}
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0))
|
|
info = backend.inspect_runtime("existing")
|
|
assert info.container_id == "replacement-generation"
|
|
assert info.sandbox_url == ""
|
|
assert backend._pending_cleanup_ports == {"old-generation": ("existing", 18080)}
|
|
|
|
|
|
def test_destroy_releases_port_after_both_containers_are_removed_despite_network_timeout(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
stopped = []
|
|
monkeypatch.setattr(backend, "_stop_container", stopped.append)
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if cmd[:3] == ["docker", "network", "rm"]:
|
|
raise subprocess.TimeoutExpired(cmd, 15)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
info = SandboxInfo("existing", "http://localhost:18080", container_name="sandbox-existing", container_id="main-generation")
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(info)
|
|
assert stopped == [proxy_name, "main-generation"]
|
|
assert released == [18080]
|
|
assert backend._pending_cleanup_ports == {}
|
|
|
|
|
|
def test_confirmed_absent_teardown_releases_a_timed_out_but_removed_generations_port_once(monkeypatch):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from deerflow.utils.network import PortAllocator
|
|
|
|
backend = _backend_for_inspect_tests()
|
|
allocator = PortAllocator()
|
|
monkeypatch.setattr(allocator, "_is_port_available", lambda port: port not in allocator._reserved_ports)
|
|
port = allocator.allocate(18080, max_range=1)
|
|
other_port = allocator.allocate(18081, max_range=1)
|
|
released = []
|
|
removed = False
|
|
|
|
def release(port):
|
|
released.append(port)
|
|
allocator.release(port)
|
|
|
|
def runtime(cmd, **kwargs):
|
|
nonlocal removed
|
|
if "stop" in cmd:
|
|
if cmd[-1].startswith("deer-flow-netproxy-"):
|
|
raise subprocess.CalledProcessError(1, cmd, stderr=f"Error: No such container: {cmd[-1]}")
|
|
if cmd[-1] == "removed-generation":
|
|
removed = True
|
|
raise subprocess.TimeoutExpired(cmd, 120)
|
|
if cmd[:3] in [["docker", "rm", "-f"], ["docker", "network", "rm"]]:
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
assert removed
|
|
return SimpleNamespace(stdout="[]", stderr="Error: No such object: sandbox-existing", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
monkeypatch.setattr(local_backend_module, "release_port", release)
|
|
for sandbox_id, generation, reserved_port in [("other", "other-generation", other_port), ("existing", "removed-generation", port)]:
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(SandboxInfo(sandbox_id, f"http://localhost:{reserved_port}", container_id=generation))
|
|
|
|
assert backend.inspect_runtime("existing") is None
|
|
assert backend.is_absent("existing") is True
|
|
assert released == [] # Metadata and presence reads never mutate reservations.
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
list(pool.map(backend.complete_absent_teardown, ["existing", "existing"]))
|
|
assert released == [port]
|
|
assert allocator.allocate(18080, max_range=1) == port
|
|
with pytest.raises(RuntimeError, match="No available port"):
|
|
allocator.allocate(18081, max_range=1)
|
|
backend.complete_absent_teardown("existing")
|
|
assert released == [port]
|
|
|
|
|
|
@pytest.mark.parametrize("restarted", [False, True])
|
|
def test_absent_teardown_removes_leftover_networks_and_allows_restricted_creation(monkeypatch, restarted):
|
|
backend = _restricted_backend()
|
|
restarted_backend = _restricted_backend() if restarted else None
|
|
proxy_name, network_name = backend._resource_names("existing")
|
|
networks = {network_name, backend._egress_network_name("existing")}
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if "stop" in cmd:
|
|
if cmd[-1] == "old-generation":
|
|
raise subprocess.TimeoutExpired(cmd, 120)
|
|
raise subprocess.CalledProcessError(1, cmd, stderr=f"Error: No such container: {cmd[-1]}")
|
|
if cmd[:3] == ["docker", "rm", "-f"]:
|
|
return SimpleNamespace(stdout="", stderr=f"Error: No such container: {proxy_name}", returncode=1)
|
|
if cmd[:3] == ["docker", "network", "rm"]:
|
|
networks.discard(cmd[-1])
|
|
return SimpleNamespace(stdout=cmd[-1], stderr="", returncode=0)
|
|
if cmd[:3] == ["docker", "network", "inspect"]:
|
|
if cmd[-1] in networks:
|
|
return SimpleNamespace(stdout=json.dumps([{"Driver": "bridge", "Internal": True}]), stderr="", returncode=0)
|
|
return SimpleNamespace(stdout="[]", stderr=f"Error response from daemon: network {cmd[-1]} not found", returncode=1)
|
|
assert cmd[:2] == ["docker", "inspect"]
|
|
return SimpleNamespace(stdout="[]", stderr="\n".join(f"Error: No such container: {name}" for name in cmd[2:]), returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(SandboxInfo("existing", "http://localhost:18080", container_id="old-generation"))
|
|
assert backend.is_absent("existing") is True
|
|
assert backend._restricted_resources_status("existing") == "mismatch"
|
|
if restarted:
|
|
backend = restarted_backend
|
|
assert backend._pending_cleanup_ports == {}
|
|
backend.complete_absent_teardown("existing")
|
|
assert backend._restricted_resources_status("existing") == "missing"
|
|
assert released == ([] if restarted else [18080])
|
|
monkeypatch.setattr(backend, "_create_internal_network", lambda *_args: None)
|
|
monkeypatch.setattr(backend, "_create_egress_network", lambda *_args: None)
|
|
monkeypatch.setattr(backend, "_start_network_proxy", lambda *_args: None)
|
|
monkeypatch.setattr(backend, "_start_container", lambda *_args, **_kwargs: "replacement-generation")
|
|
assert backend._start_restricted_sandbox("existing", "sandbox-existing", 18080, None, config_mount_exclusion_root=None, relay_token="token") == "replacement-generation"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"stderr, absent",
|
|
[
|
|
("Error response from daemon: network sandbox-net-x not found", True),
|
|
("error: network sandbox-net-x not found", True), # Docker 29 lowercase
|
|
("Error: No such network: sandbox-net-x", True),
|
|
# A different network's absence, a context failure, or a lookalike
|
|
# must raise instead of reading as absent.
|
|
("Error response from daemon: network sandbox-net-x-extra not found", False),
|
|
('context "sandbox-net-x": context not found', False),
|
|
("command not found: docker network inspect", False),
|
|
],
|
|
)
|
|
def test_inspect_network_uses_strict_missing_network_parser(monkeypatch, stderr, absent):
|
|
"""The non-strict inspect path shares the strict parser: context-name
|
|
collisions and wrong-target errors raise instead of reading as absent."""
|
|
backend = _restricted_backend()
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda *a, **k: SimpleNamespace(stdout="[]", stderr=stderr, returncode=1),
|
|
)
|
|
|
|
if absent:
|
|
assert backend._inspect_network("sandbox-net-x") is None
|
|
else:
|
|
with pytest.raises(RuntimeError):
|
|
backend._inspect_network("sandbox-net-x")
|
|
|
|
|
|
@pytest.mark.parametrize("error_kind", ["context", "wrong-target", "wrong-target-lowercase", "case-drift", "active-endpoint", "prefix", "suffix", "mixed-stderr", "empty"])
|
|
def test_absent_teardown_preserves_pending_port_on_unknown_network_cleanup_error(monkeypatch, error_kind):
|
|
backend = _restricted_backend()
|
|
backend._pending_cleanup_ports["old-generation"] = ("existing", 18080)
|
|
proxy_name, network_name = backend._resource_names("existing")
|
|
errors = {
|
|
"context": f'context "{network_name}": context not found',
|
|
"wrong-target": f"Error response from daemon: network {network_name}-extra not found",
|
|
"wrong-target-lowercase": f"error: network {network_name}-extra not found",
|
|
"case-drift": f"error: network {network_name.upper()} not found",
|
|
"active-endpoint": f"Error response from daemon: network {network_name} has active endpoints",
|
|
"prefix": f"prefix error: network {network_name} not found",
|
|
"suffix": f"error: network {network_name} not found suffix",
|
|
"mixed-stderr": f"error: network {network_name} not found\nError response from daemon: permission denied",
|
|
"empty": "",
|
|
}
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
monkeypatch.setattr(backend, "_stop_container", lambda *_args: None)
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if cmd == ["docker", "rm", "-f", proxy_name]:
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
return SimpleNamespace(stdout="", stderr=errors[error_kind], returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
with pytest.raises(RuntimeError):
|
|
backend.complete_absent_teardown("existing")
|
|
assert released == []
|
|
assert backend._pending_cleanup_ports == {"old-generation": ("existing", 18080)}
|
|
|
|
|
|
@pytest.mark.parametrize("error_style", ["legacy", "lowercase-scaffolding", "uppercase-scaffolding"])
|
|
@pytest.mark.parametrize("sandbox_id", ["existing", "Mixed-Case"])
|
|
def test_absent_teardown_accepts_only_exact_missing_networks(monkeypatch, error_style, sandbox_id):
|
|
backend = _restricted_backend()
|
|
backend._pending_cleanup_ports["old-generation"] = (sandbox_id, 18080)
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
monkeypatch.setattr(backend, "_stop_container", lambda *_args: None)
|
|
commands = []
|
|
# Docker CLI v29 lowercases client-side error scaffolding; the daemon-side
|
|
# wrapper is stable, but both shapes must prove absence identically.
|
|
error_formats = {
|
|
"legacy": ("Error: No such container: {}", "Error response from daemon: network {} not found"),
|
|
"lowercase-scaffolding": ("error: no such object: {}", "error: network {} not found"),
|
|
"uppercase-scaffolding": ("ERROR: NO SUCH OBJECT: {}", "ERROR: NETWORK {} NOT FOUND"),
|
|
}
|
|
container_error, network_error = error_formats[error_style]
|
|
|
|
def runtime(cmd, **kwargs):
|
|
commands.append(cmd)
|
|
if cmd[:3] == ["docker", "rm", "-f"]:
|
|
return SimpleNamespace(stdout="", stderr=container_error.format(cmd[-1]), returncode=1)
|
|
return SimpleNamespace(stdout="", stderr=network_error.format(cmd[-1]), returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
backend.complete_absent_teardown(sandbox_id)
|
|
assert released == [18080]
|
|
assert backend._pending_cleanup_ports == {}
|
|
assert ["docker", "network", "rm", backend._resource_names(sandbox_id)[1]] in commands
|
|
assert ["docker", "network", "rm", backend._egress_network_name(sandbox_id)] in commands
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("message", "expected"),
|
|
[
|
|
("Error response from daemon: network sandbox-net not found", True),
|
|
("error response from daemon: network sandbox-net not found", True),
|
|
("error: network sandbox-net not found", True),
|
|
("Error: No such network: sandbox-net", True),
|
|
("error: no such network: sandbox-net", True),
|
|
("No such network: sandbox-net", True),
|
|
("network sandbox-net not found", True),
|
|
("error: network sandbox-net-extra not found", False),
|
|
("error: network Sandbox-Net not found", False),
|
|
("error: no such network: sandbox-other", False),
|
|
("error: network sandbox-net has active endpoints", False),
|
|
('context "sandbox-net": context not found', False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_is_no_such_network_error_matches_scaffolding_case_insensitively(message, expected):
|
|
assert local_backend_module._is_no_such_network_error(message, "sandbox-net") is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("message", "expected"),
|
|
[
|
|
# Docker <=28 client-side and daemon-side forms.
|
|
("Error: No such object: sandbox-x", True),
|
|
("Error response from daemon: No such container: sandbox-x", True),
|
|
("No such object: sandbox-x", True),
|
|
# Docker 29 lowercases the client-side scaffolding.
|
|
("error: no such object: sandbox-x", True),
|
|
("error response from daemon: no such container: sandbox-x", True),
|
|
# Scaffolding is normalized; the name stays byte-exact and the whole
|
|
# message must match.
|
|
("error: no such object: Sandbox-X", False),
|
|
("error: no such object: sandbox-x-extra", False),
|
|
("error: no such object", False),
|
|
("error: no such image: sandbox-x", False),
|
|
("prefix error: no such object: sandbox-x", False),
|
|
("error: no such object: sandbox-x suffix", False),
|
|
# Apple Container forms stay exact-match.
|
|
("Error: container not found: sandbox-x", True),
|
|
('notFound: "container not found: sandbox-x"', True),
|
|
('Error: not found: "sandbox-x"', True),
|
|
("error: container not found: sandbox-x", False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_is_no_such_container_error_matches_scaffolding_case_insensitively(message, expected):
|
|
assert local_backend_module._is_no_such_container_error(message, "sandbox-x") is expected
|
|
|
|
|
|
def test_apple_absent_teardown_releases_own_unknown_generation_port_after_timeout(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = "container"
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if "stop" in cmd:
|
|
raise subprocess.TimeoutExpired(cmd, 120)
|
|
assert cmd[:2] == ["container", "inspect"]
|
|
return SimpleNamespace(stdout="[]", stderr="Error: container not found: sandbox-existing", returncode=1)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(SandboxInfo("other", "http://localhost:18081", container_name="sandbox-other"))
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(SandboxInfo("existing", "http://localhost:18080", container_name="sandbox-existing"))
|
|
assert backend.inspect_runtime("existing") is None
|
|
assert backend.is_absent("existing") is True
|
|
assert released == []
|
|
backend.complete_absent_teardown("existing")
|
|
backend.complete_absent_teardown("existing")
|
|
assert released == [18080]
|
|
backend.complete_absent_teardown("other")
|
|
assert released == [18080, 18081]
|
|
|
|
|
|
def test_apple_present_unknown_generation_does_not_inherit_pending_port(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = "container"
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
|
|
def runtime(cmd, **kwargs):
|
|
if "stop" in cmd:
|
|
raise subprocess.TimeoutExpired(cmd, 120)
|
|
entry = {"configuration": {"id": "sandbox-existing"}, "status": "running"}
|
|
return SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(SandboxInfo("existing", "http://localhost:18080", container_name="sandbox-existing"))
|
|
info = backend.inspect_runtime("existing")
|
|
assert info is not None
|
|
assert info.container_id is None
|
|
assert info.sandbox_url == ""
|
|
assert backend.is_absent("existing") is False
|
|
assert released == []
|
|
|
|
|
|
@pytest.mark.parametrize("runtime", ["container", "docker"])
|
|
def test_successful_unknown_generation_retry_does_not_release_a_reused_port_again(monkeypatch, runtime):
|
|
from deerflow.utils.network import PortAllocator
|
|
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = runtime
|
|
allocator = PortAllocator()
|
|
monkeypatch.setattr(allocator, "_is_port_available", lambda port: port not in allocator._reserved_ports)
|
|
port = allocator.allocate(18080, max_range=1)
|
|
failed = False
|
|
released = []
|
|
|
|
def release(port):
|
|
released.append(port)
|
|
allocator.release(port)
|
|
|
|
def stop(_target):
|
|
nonlocal failed
|
|
if not failed:
|
|
failed = True
|
|
raise subprocess.TimeoutExpired("stop", 120)
|
|
|
|
monkeypatch.setattr(backend, "_stop_container", stop)
|
|
monkeypatch.setattr(local_backend_module, "release_port", release)
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout="", stderr="", returncode=0))
|
|
original = SandboxInfo("existing", f"http://localhost:{port}", container_name="sandbox-existing")
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(original)
|
|
# Old Docker metadata can learn its immutable generation on a retry.
|
|
if runtime == "docker":
|
|
original.container_id = "learned-generation"
|
|
backend.destroy(original)
|
|
assert allocator.allocate(18080, max_range=1) == port
|
|
backend.complete_absent_teardown("existing")
|
|
assert released == [port]
|
|
with pytest.raises(RuntimeError, match="No available port"):
|
|
allocator.allocate(18080, max_range=1)
|
|
|
|
|
|
def test_absent_teardown_deduplicates_a_legacy_reservation_that_later_learns_its_generation(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
released = []
|
|
monkeypatch.setattr(local_backend_module, "release_port", released.append)
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout="", stderr="", returncode=0))
|
|
|
|
def timeout(_target):
|
|
raise subprocess.TimeoutExpired("stop", 120)
|
|
|
|
monkeypatch.setattr(backend, "_stop_container", timeout)
|
|
info = SandboxInfo("existing", "http://localhost:18080", container_name="sandbox-existing")
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(info)
|
|
info.container_id = "learned-generation"
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend.destroy(info)
|
|
monkeypatch.setattr(backend, "_stop_container", lambda *_args: None)
|
|
backend.complete_absent_teardown("existing")
|
|
backend.complete_absent_teardown("existing")
|
|
assert released == [18080]
|
|
|
|
|
|
@pytest.mark.parametrize("failed_stage", ["proxy-stop", "proxy-remove", "main-stop"])
|
|
def test_restricted_partial_destroy_keeps_a_retryable_main_and_releases_port_once(monkeypatch, failed_stage):
|
|
from deerflow.utils.network import PortAllocator
|
|
|
|
backend = _backend_for_inspect_tests()
|
|
backend._network_mode = "allowlist"
|
|
allocator = PortAllocator()
|
|
monkeypatch.setattr(allocator, "_is_port_available", lambda port: port not in allocator._reserved_ports)
|
|
port = allocator.allocate(18080, max_range=1)
|
|
released = []
|
|
|
|
def release(port):
|
|
released.append(port)
|
|
allocator.release(port)
|
|
|
|
monkeypatch.setattr(local_backend_module, "release_port", release)
|
|
proxy_name, _ = backend._resource_names("existing")
|
|
main = {"Id": "main-generation", "Name": "/sandbox-existing", "Config": {"Labels": {"deerflow.network_mode": "allowlist"}}}
|
|
proxy = {"Id": "proxy-generation", "Name": f"/{proxy_name}", "HostConfig": {"PortBindings": {"8080/tcp": [{"HostPort": str(port)}]}}}
|
|
removed = set()
|
|
failed = False
|
|
|
|
def runtime(cmd, **kwargs):
|
|
nonlocal failed
|
|
target = cmd[-1]
|
|
if "inspect" in cmd:
|
|
if target in removed:
|
|
return SimpleNamespace(stdout="[]", stderr=f"Error: No such object: {target}", returncode=1)
|
|
return SimpleNamespace(stdout=json.dumps([proxy if target == proxy_name else main]), stderr="", returncode=0)
|
|
stage = "proxy-remove" if cmd[:3] == ["docker", "rm", "-f"] else "proxy-stop" if "stop" in cmd and target == proxy_name else "main-stop" if "stop" in cmd else None
|
|
if stage == failed_stage and not failed:
|
|
failed = True
|
|
if kwargs.get("check"):
|
|
raise subprocess.CalledProcessError(1, cmd, stderr="daemon denied teardown")
|
|
return SimpleNamespace(stdout="", stderr="daemon denied teardown", returncode=1)
|
|
if stage == "main-stop":
|
|
removed.add("sandbox-existing")
|
|
elif stage == "proxy-remove":
|
|
removed.add(proxy_name)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
original = SandboxInfo("existing", f"http://localhost:{port}", container_name="sandbox-existing", container_id="main-generation")
|
|
with pytest.raises((RuntimeError, subprocess.CalledProcessError)):
|
|
backend.destroy(original)
|
|
assert backend.is_absent("existing") is False
|
|
assert released == []
|
|
with pytest.raises(RuntimeError, match="No available port"):
|
|
allocator.allocate(18080, max_range=1)
|
|
recovered = backend.inspect_runtime("existing")
|
|
assert recovered is not None
|
|
backend.destroy(recovered)
|
|
assert allocator.allocate(18080, max_range=1) == port
|
|
assert released == [port]
|
|
|
|
|
|
def test_apple_list_does_not_mark_unsupported_runtime_metadata_for_replacement(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = "container"
|
|
entry = {"configuration": {"id": "sandbox-existing"}, "status": "running"}
|
|
|
|
def runtime(cmd, **kwargs):
|
|
return SimpleNamespace(stdout="sandbox-existing\n" if "ps" in cmd else json.dumps([entry]), stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
assert backend.list_running() == []
|
|
assert backend.inspect_runtime("existing") is not None
|
|
|
|
|
|
def test_apple_batch_preserves_legacy_docker_shaped_metadata(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = "container"
|
|
entry = {"Name": "/sandbox-existing", "NetworkSettings": {"Ports": {"8080/tcp": [{"HostPort": "18080"}]}}}
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout=json.dumps([entry]), stderr="", returncode=0))
|
|
assert backend._batch_inspect(["sandbox-existing"], strict=True)["sandbox-existing"].host_port == 18080
|
|
|
|
|
|
@pytest.mark.parametrize("runtime", ["docker", "container"])
|
|
def test_inspect_runtime_only_returns_none_for_authoritative_absence(monkeypatch, runtime):
|
|
backend = _backend_for_inspect_tests()
|
|
backend._runtime = runtime
|
|
error = "Error: No such object: sandbox-missing" if runtime == "docker" else "Error: container not found: sandbox-missing"
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout="[]", stderr=error, returncode=1))
|
|
|
|
assert backend.inspect_runtime("missing") is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
"",
|
|
"[]",
|
|
"{}",
|
|
"not JSON",
|
|
json.dumps([{"Name": "/sandbox-other", "Id": "generation"}]),
|
|
json.dumps([{"Name": "/sandbox-existing"}]),
|
|
json.dumps([{"Name": "/sandbox-existing", "Id": 42}]),
|
|
json.dumps([{"Name": "/sandbox-existing", "Id": "generation"}, {"Name": "/sandbox-existing", "Id": "other-generation"}]),
|
|
],
|
|
)
|
|
def test_inspect_runtime_fails_closed_on_incomplete_or_invalid_docker_response(monkeypatch, payload):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout=payload, stderr="", returncode=0))
|
|
|
|
with pytest.raises(RuntimeError):
|
|
backend.inspect_runtime("existing")
|
|
with pytest.raises(RuntimeError):
|
|
backend.is_absent("existing")
|
|
|
|
|
|
@pytest.mark.parametrize("failure", [OSError("runtime unavailable"), subprocess.TimeoutExpired("inspect", 5)])
|
|
def test_inspect_runtime_propagates_runtime_failures(monkeypatch, failure):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def failed(*_args, **_kwargs):
|
|
raise failure
|
|
|
|
monkeypatch.setattr("subprocess.run", failed)
|
|
with pytest.raises(RuntimeError):
|
|
backend.inspect_runtime("existing")
|
|
|
|
|
|
def test_batch_inspect_keeps_present_generations_with_exact_partial_absence(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
entry = {"Name": "/sandbox-existing", "Id": "immutable-generation"}
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout=json.dumps([entry]), stderr="Error: No such object: sandbox-missing", returncode=1))
|
|
|
|
inspections = backend._batch_inspect(["sandbox-existing", "sandbox-missing"], strict=True)
|
|
|
|
assert list(inspections) == ["sandbox-existing"]
|
|
assert inspections["sandbox-existing"].container_id == "immutable-generation"
|
|
|
|
|
|
def test_batch_inspect_does_not_accept_absence_of_an_unrequested_resource(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: SimpleNamespace(stdout="[]", stderr="Error: No such object: sandbox-other", returncode=1))
|
|
|
|
with pytest.raises(RuntimeError):
|
|
backend._batch_inspect(["sandbox-existing"], strict=True)
|
|
|
|
|
|
def test_stop_container_passes_a_timeout(monkeypatch):
|
|
"""An unbounded `stop` can outlive the teardown lease that guards it.
|
|
|
|
The `del:` marker keeps a peer from re-acquiring the container during the
|
|
stop, but a lease can lapse (a store outage longer than the TTL) while a
|
|
wedged daemon leaves `docker stop` blocked forever — and the stop then lands
|
|
on a container the peer has since been handed. Bounding the call caps that
|
|
exposure independently of the ownership layer.
|
|
"""
|
|
backend = _backend_for_inspect_tests()
|
|
seen = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
seen.update(kwargs)
|
|
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
backend._stop_container("sandbox-slow")
|
|
|
|
assert seen.get("timeout") == backend._STOP_TIMEOUT_SECONDS
|
|
|
|
|
|
def test_failed_stop_is_not_reported_as_destroyed(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def denied(cmd, **_kwargs):
|
|
raise subprocess.CalledProcessError(1, cmd, stderr="daemon denied stop")
|
|
|
|
monkeypatch.setattr("subprocess.run", denied)
|
|
with pytest.raises(subprocess.CalledProcessError):
|
|
backend.destroy(SandboxInfo("sandbox", "http://localhost:18080", container_id="generation"))
|
|
|
|
|
|
def test_docker_discovery_preserves_runtime_generation(monkeypatch):
|
|
import json
|
|
|
|
import requests
|
|
|
|
backend = _backend_for_inspect_tests()
|
|
payload = [
|
|
{
|
|
"Id": "docker-generation",
|
|
"Name": "/sandbox-existing",
|
|
"Config": {"Image": "sandbox:latest", "Labels": {"deerflow.role": "sandbox", "deerflow.sandbox_id": "existing", "deerflow.network_mode": "open"}},
|
|
"NetworkSettings": {"Ports": {"8080/tcp": [{"HostPort": "18080"}]}},
|
|
}
|
|
]
|
|
|
|
def runtime(cmd, **_kwargs):
|
|
return SimpleNamespace(returncode=0, stdout="true" if "-f" in cmd else json.dumps(payload), stderr="")
|
|
|
|
monkeypatch.setattr("subprocess.run", runtime)
|
|
monkeypatch.setattr(requests.Session, "get", lambda *_args, **_kwargs: SimpleNamespace(status_code=200))
|
|
|
|
info = backend.discover("existing")
|
|
assert info is not None
|
|
assert info.container_id == "docker-generation"
|
|
|
|
|
|
def test_stop_container_propagates_a_timeout_instead_of_reporting_success(monkeypatch):
|
|
"""A timed-out stop must not be swallowed like a failed one.
|
|
|
|
`CalledProcessError` means the runtime answered "I could not stop it"; a
|
|
timeout means we do not know, and the container is probably still running.
|
|
Returning normally would let `_destroy_warm_entry` report a clean stop and
|
|
drop the warm entry, leaking a running container nothing tracks.
|
|
"""
|
|
backend = _backend_for_inspect_tests()
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
backend._stop_container("sandbox-wedged")
|
|
|
|
|
|
# ── Extended network syntax and IPv6 host normalization ──────────────────────
|
|
|
|
|
|
def test_start_container_rejects_extended_network_syntax_host(monkeypatch):
|
|
"""name=host attaches the host network namespace exactly like `host`;
|
|
the raw string must not dodge the rejection."""
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=host")
|
|
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_rejects_extended_network_syntax_none(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=none")
|
|
|
|
with pytest.raises(RuntimeError, match="loopback-only"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_rejects_extended_network_syntax_container(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=container:gateway")
|
|
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_passes_extended_network_syntax_for_custom_networks(monkeypatch):
|
|
"""The legit name=<custom-net> long form (and network IDs) keep working."""
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=deer-flow-sandbox-egress")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("--network") + 1] == "name=deer-flow-sandbox-egress"
|
|
|
|
|
|
@pytest.mark.parametrize("sandbox_host", ["fd00::1", "[fd00::1]"])
|
|
def test_discover_brackets_ipv6_sandbox_host_for_url(monkeypatch, sandbox_host):
|
|
"""Both IPv6 input forms must yield the same bracketed URL authority:
|
|
the bare form used to produce the malformed http://fd00::1:<port>."""
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host)
|
|
monkeypatch.setattr(backend, "_is_container_running", lambda name: True)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_batch_inspect",
|
|
lambda *_args, **_kwargs: {
|
|
"sandbox-sbx-ipv6": _ContainerInspection(
|
|
1.0,
|
|
18081,
|
|
{},
|
|
"sandbox:latest",
|
|
frozenset({"bridge"}),
|
|
)
|
|
},
|
|
)
|
|
|
|
seen_urls = []
|
|
|
|
def fake_ready(url, timeout):
|
|
seen_urls.append(url)
|
|
return True
|
|
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.wait_for_sandbox_ready", fake_ready)
|
|
|
|
info = backend.discover("sbx-ipv6")
|
|
|
|
assert info.sandbox_url == "http://[fd00::1]:18081"
|
|
assert seen_urls == ["http://[fd00::1]:18081"]
|
|
|
|
|
|
@pytest.mark.parametrize("sandbox_host", ["fd00::1", "[fd00::1]"])
|
|
def test_create_brackets_ipv6_sandbox_host_for_url(monkeypatch, sandbox_host):
|
|
backend = _backend_for_inspect_tests()
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host)
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_start_container",
|
|
lambda name, port, mounts=None, **_kwargs: "container-id",
|
|
)
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.get_free_port", lambda start_port=None: 18082)
|
|
|
|
info = backend.create(thread_id="t", sandbox_id="sbx-ipv6")
|
|
|
|
assert info.sandbox_url == "http://[fd00::1]:18082"
|
|
|
|
|
|
def test_resolve_sandbox_host_address_accepts_bracketed_ipv6(monkeypatch):
|
|
"""The bracketed form must resolve (unbracketed for getaddrinfo) instead
|
|
of failing through to the IPv4 bridge fallback."""
|
|
from deerflow.community.aio_sandbox.local_backend import _resolve_sandbox_host_address
|
|
|
|
infos = [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fd00::1", 0, 0, 0))]
|
|
|
|
def fake_getaddrinfo(host, port, *args, **kwargs):
|
|
assert host == "fd00::1", f"getaddrinfo must receive the unbracketed form, got {host!r}"
|
|
return infos
|
|
|
|
monkeypatch.setattr("deerflow.community.aio_sandbox.local_backend.socket.getaddrinfo", fake_getaddrinfo)
|
|
|
|
assert _resolve_sandbox_host_address("[fd00::1]") == "[fd00::1]"
|
|
|
|
|
|
# ── Long-syntax fields in any position (Docker opts/network.go semantics) ────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"network",
|
|
[
|
|
"name=host,gw-priority=0",
|
|
"gw-priority=0,name=host",
|
|
"name=host,alias=sbx",
|
|
],
|
|
)
|
|
def test_start_container_rejects_host_with_additional_long_syntax_fields(monkeypatch, network):
|
|
"""Docker accepts comma-separated fields in any order; both orderings
|
|
select the host network and must not dodge the rejection."""
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", network)
|
|
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_rejects_none_with_additional_long_syntax_fields(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "gw-priority=0,name=none")
|
|
|
|
with pytest.raises(RuntimeError, match="loopback-only"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_rejects_container_mode_with_additional_long_syntax_fields(monkeypatch):
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=container:gateway,gw-priority=0")
|
|
|
|
with pytest.raises(RuntimeError, match="DEER_FLOW_SANDBOX_NETWORK"):
|
|
_capture_start_container_command(monkeypatch, backend)
|
|
|
|
|
|
def test_start_container_passes_long_syntax_custom_network_with_fields(monkeypatch):
|
|
"""A legit long-syntax value with extra fields keeps passing through verbatim."""
|
|
backend = _backend_for_inspect_tests()
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_NETWORK", "name=egressnet,gw-priority=1")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert captured_cmd[captured_cmd.index("--network") + 1] == "name=egressnet,gw-priority=1"
|
|
|
|
|
|
def test_effective_network_target_last_name_field_wins():
|
|
"""Docker's parser lets a later name= field overwrite an earlier one."""
|
|
from deerflow.community.aio_sandbox.local_backend import _effective_docker_network_target as target
|
|
|
|
assert target("name=host,name=egressnet") == "egressnet"
|
|
assert target("name=egressnet,name=host") == "host"
|
|
assert target("gw-priority=0") == "gw-priority=0" # no name= field: Docker errors itself
|
|
assert target("bridge") == "bridge"
|
|
assert target("1f2a" * 16) == "1f2a" * 16 # network ID passes through
|
|
|
|
|
|
# ── Real-image startup smoke tests (docker-gated) ────────────────────────────
|
|
# Keep the baseline default in sync with aio_sandbox_provider.DEFAULT_IMAGE.
|
|
_DEFAULT_AIO_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"
|
|
# Dedicated regression target for #5161. The workflow resolves this tag to an
|
|
# immutable digest before pytest runs so the CI result records the exact image.
|
|
_FOWNER_REGRESSION_AIO_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:1.11.0"
|
|
|
|
|
|
def _docker_daemon_available() -> bool:
|
|
try:
|
|
subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _assert_image_starts_under_hardened_capabilities(
|
|
monkeypatch,
|
|
*,
|
|
image: str,
|
|
sandbox_id: str,
|
|
failure_label: str,
|
|
) -> None:
|
|
from deerflow.community.aio_sandbox.backend import SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT
|
|
from deerflow.community.aio_sandbox.local_backend import wait_for_sandbox_ready
|
|
|
|
if not _docker_daemon_available():
|
|
pytest.skip("requires a running Docker daemon")
|
|
|
|
backend = LocalContainerBackend(
|
|
image=image,
|
|
base_port=18210,
|
|
container_prefix="sandbox-smoke",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
|
|
info = backend.create(thread_id="smoke", sandbox_id=sandbox_id)
|
|
try:
|
|
# The production deadline, single-sourced: the sync and async
|
|
# provider paths destroy the container after exactly this budget, so
|
|
# a longer one here could pass while every real acquisition fails.
|
|
# (create() completes docker run — including the image pull — before
|
|
# this timer starts, so the pull is not part of the budget.)
|
|
ready = wait_for_sandbox_ready(info.sandbox_url, timeout=SANDBOX_LOCAL_PROVIDER_READY_TIMEOUT)
|
|
if not ready:
|
|
# Fail diagnosably: the entrypoint's own log tells us whether the
|
|
# capability set is still incomplete (chown/chmod/useradd/su
|
|
# errors) or the services are merely slow.
|
|
logs = subprocess.run(
|
|
["docker", "logs", info.container_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
# supervisord only reports exit codes in the container log; the
|
|
# failing program's own stderr goes to files inside the container.
|
|
# Pull the usual suspects so the failure is actionable in CI.
|
|
prog_logs = subprocess.run(
|
|
[
|
|
"docker",
|
|
"exec",
|
|
info.container_name,
|
|
"sh",
|
|
"-c",
|
|
"cat /var/log/supervisor/* 2>/dev/null | tail -n 40; echo '--- nginx -t ---'; nginx -t 2>&1; echo '--- nginx error.log ---'; tail -n 20 /var/log/nginx/error.log 2>/dev/null",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
tail = "\n".join((logs.stdout + logs.stderr).splitlines()[-40:]) + "\n" + (prog_logs.stdout or "")
|
|
pytest.fail(f"{failure_label} never became ready under the hardened capabilities: {info.sandbox_url}\n--- last 40 container log lines ---\n{tail}")
|
|
assert backend.is_alive(info)
|
|
finally:
|
|
backend.destroy(info)
|
|
|
|
|
|
# `live`: pulls and runs external images, so the default offline suite
|
|
# (`make test` = `-m "not live"`) never touches the network. The daemon probe
|
|
# happens inside the helper — never at collection time.
|
|
@pytest.mark.live
|
|
def test_default_image_starts_under_hardened_capabilities(monkeypatch):
|
|
"""Preserve real-image coverage for the configured/default AIO image.
|
|
|
|
This stays separate from the 1.11.0 regression because older images may
|
|
not execute the FOWNER-gated chmod added by the newer startup path.
|
|
"""
|
|
_assert_image_starts_under_hardened_capabilities(
|
|
monkeypatch,
|
|
image=os.environ.get("DEER_FLOW_SANDBOX_SMOKE_IMAGE", _DEFAULT_AIO_IMAGE),
|
|
sandbox_id="caps-smoke-default",
|
|
failure_label="configured/default image",
|
|
)
|
|
|
|
|
|
@pytest.mark.live
|
|
def test_aio_1_11_image_starts_with_fowner_capability(monkeypatch):
|
|
"""Regression smoke for #5161 against the recommended AIO 1.11.0 image.
|
|
|
|
Its entrypoint chmods /run/user/1000 after capabilities are dropped.
|
|
Without FOWNER that startup path exits before readiness; reaching the
|
|
endpoint proves the five-capability compatibility set covers the bug.
|
|
"""
|
|
_assert_image_starts_under_hardened_capabilities(
|
|
monkeypatch,
|
|
image=os.environ.get("DEER_FLOW_SANDBOX_FOWNER_SMOKE_IMAGE", _FOWNER_REGRESSION_AIO_IMAGE),
|
|
sandbox_id="caps-smoke-fowner-1-11",
|
|
failure_label="AIO 1.11.0 FOWNER regression image",
|
|
)
|
|
|
|
|
|
@pytest.mark.live
|
|
def test_restricted_network_proxy_enforces_and_approves_real_traffic(monkeypatch):
|
|
"""Exercise the Engine-28 bridge, API relay, proxy, event, and grant path."""
|
|
if not _docker_daemon_available():
|
|
pytest.skip("requires a running Docker daemon")
|
|
|
|
image = os.environ.get("DEER_FLOW_SANDBOX_NETWORK_SMOKE_IMAGE", "python:3.12-alpine")
|
|
backend = LocalContainerBackend(
|
|
image=image,
|
|
base_port=18310,
|
|
container_prefix="sandbox-policy-smoke",
|
|
config_mounts=[],
|
|
environment={},
|
|
network_config={
|
|
"mode": "allowlist",
|
|
"allow_domains": ["pypi.org"],
|
|
"approval": "prompt",
|
|
"temporary_grant_ttl": 300,
|
|
"proxy_image": image,
|
|
},
|
|
)
|
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
|
sandbox_id = "network-live"
|
|
container_name = f"sandbox-policy-smoke-{sandbox_id}"
|
|
proxy_name, network_name = backend._resource_names(sandbox_id)
|
|
egress_network_name = backend._egress_network_name(sandbox_id)
|
|
port = get_free_port(start_port=18310)
|
|
relay_token = "live-relay-token-that-is-at-least-32-bytes"
|
|
proxy_url = f"http://{proxy_name}:3128"
|
|
|
|
try:
|
|
assert backend._restricted_resources_status(sandbox_id) == "missing"
|
|
backend._create_internal_network(network_name, sandbox_id)
|
|
backend._create_egress_network(egress_network_name, sandbox_id)
|
|
backend._start_network_proxy(proxy_name, network_name, egress_network_name, container_name, port, sandbox_id, relay_token)
|
|
|
|
proxy_inspect = subprocess.run(
|
|
["docker", "inspect", proxy_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
assert proxy_inspect.returncode == 0, proxy_inspect.stderr
|
|
proxy_networks = json.loads(proxy_inspect.stdout)[0]["NetworkSettings"]["Networks"]
|
|
assert set(proxy_networks) == {network_name, egress_network_name}
|
|
assert "bridge" not in proxy_networks
|
|
proxy_egress_ip = proxy_networks[egress_network_name]["IPAddress"]
|
|
outside = subprocess.run(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--network",
|
|
"bridge",
|
|
image,
|
|
"python",
|
|
"-c",
|
|
(f"import socket,sys\ntry:\n socket.create_connection(({proxy_egress_ip!r}, 8080), timeout=2)\nexcept OSError:\n sys.exit(0)\nsys.exit(42)"),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
assert outside.returncode == 0, outside.stderr or "a container on Docker's shared bridge reached the sandbox API relay"
|
|
sandbox_labels = backend._restricted_labels(sandbox_id, "sandbox")
|
|
sandbox = subprocess.run(
|
|
[
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"-d",
|
|
"--network",
|
|
network_name,
|
|
"--name",
|
|
container_name,
|
|
*(item for key, value in sandbox_labels.items() for item in ("--label", f"{key}={value}")),
|
|
"-e",
|
|
f"HTTP_PROXY={proxy_url}",
|
|
"-e",
|
|
f"HTTPS_PROXY={proxy_url}",
|
|
"-e",
|
|
f"http_proxy={proxy_url}",
|
|
"-e",
|
|
f"https_proxy={proxy_url}",
|
|
image,
|
|
"python",
|
|
"-m",
|
|
"http.server",
|
|
"8080",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
assert sandbox.returncode == 0, sandbox.stderr
|
|
assert backend._restricted_resources_status(sandbox_id) == "compatible"
|
|
backend._network_config["allow_domains"] = ["changed.example"]
|
|
assert backend._restricted_resources_status(sandbox_id) == "mismatch"
|
|
backend._network_config["allow_domains"] = ["pypi.org"]
|
|
|
|
sandbox_url = f"http://127.0.0.1:{port}"
|
|
unauthenticated = subprocess.run(
|
|
["curl", "--fail", "--silent", "--max-time", "2", sandbox_url],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert unauthenticated.returncode != 0
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
relay = subprocess.run(
|
|
[
|
|
"curl",
|
|
"--fail",
|
|
"--silent",
|
|
"--max-time",
|
|
"2",
|
|
"-H",
|
|
f"X-DeerFlow-Relay-Token: {relay_token}",
|
|
sandbox_url,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if relay.returncode == 0:
|
|
break
|
|
time.sleep(0.25)
|
|
assert relay.returncode == 0, relay.stderr
|
|
|
|
def sandbox_fetch(url: str, *, use_proxy: bool = True) -> subprocess.CompletedProcess[str]:
|
|
proxy_handler = "urllib.request.ProxyHandler()" if use_proxy else "urllib.request.ProxyHandler({})"
|
|
code = f"import urllib.request; opener=urllib.request.build_opener({proxy_handler}); print(opener.open({url!r}, timeout=15).status)"
|
|
return subprocess.run(
|
|
["docker", "exec", container_name, "python", "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=25,
|
|
)
|
|
|
|
allowed = sandbox_fetch("https://pypi.org/simple/")
|
|
assert allowed.returncode == 0, allowed.stderr
|
|
assert allowed.stdout.strip() == "200"
|
|
|
|
denied = sandbox_fetch("https://example.com/")
|
|
assert denied.returncode != 0
|
|
events = backend.consume_network_policy_events(sandbox_id)
|
|
assert [(event["host"], event["port"]) for event in events] == [("example.com", 443)]
|
|
|
|
request_id = str(events[0]["request_id"])
|
|
assert backend.decide_network_policy_request(sandbox_id, request_id, "allow_temporary")
|
|
approved = sandbox_fetch("https://example.com/")
|
|
assert approved.returncode == 0, approved.stderr
|
|
assert approved.stdout.strip() == "200"
|
|
|
|
metadata = sandbox_fetch("http://169.254.169.254/latest/meta-data/")
|
|
assert metadata.returncode != 0
|
|
assert backend.consume_network_policy_events(sandbox_id) == []
|
|
|
|
direct = sandbox_fetch("https://example.com/", use_proxy=False)
|
|
assert direct.returncode != 0
|
|
finally:
|
|
backend._cleanup_restricted_resources(sandbox_id)
|
|
release_port(port)
|
|
|
|
|
|
def test_start_container_preinitialized_image_can_drop_startup_caps(monkeypatch):
|
|
"""A custom, pre-initialized non-root image never runs the root handoff,
|
|
so CHOWN/FOWNER/SETUID/SETGID/DAC_OVERRIDE must not stay available for
|
|
the container's lifetime (chown/chmod on bind mounts, UID/GID
|
|
impersonation). Opting out with DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS=0
|
|
drops every capability."""
|
|
backend = LocalContainerBackend(
|
|
image="my-preinitialized-sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
_clear_hardening_env(monkeypatch)
|
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS", "0")
|
|
|
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
|
|
|
assert "--cap-drop=ALL" in captured_cmd
|
|
assert not [arg for arg in captured_cmd if arg.startswith("--cap-add=")]
|
|
security_opts = [captured_cmd[i + 1] for i, arg in enumerate(captured_cmd) if arg == "--security-opt"]
|
|
assert "no-new-privileges" in security_opts
|
|
|
|
|
|
def test_docker_subprocess_calls_pin_utf8_decoding(monkeypatch):
|
|
"""Locale-default text decoding silently loses docker CLI output on hosts
|
|
whose ANSI code page is not UTF-8 (e.g. cp936 Chinese Windows): the decode
|
|
error surfaces inside subprocess's reader thread, so ``stdout``/``stderr``
|
|
come back ``None``, and JSON-parsing call sites either crash on ``None``
|
|
or silently mis-parse. Every text-mode call in this module must therefore
|
|
pin ``encoding="utf-8"`` with ``errors="replace"``.
|
|
"""
|
|
seen: list[dict] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
seen.append(kwargs)
|
|
return SimpleNamespace(stdout='"Debian GNU/Linux 15"\n', stderr="", returncode=0)
|
|
|
|
_docker_server_is_desktop.cache_clear()
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
try:
|
|
backend = LocalContainerBackend(
|
|
image="sandbox:latest",
|
|
base_port=8080,
|
|
container_prefix="sandbox",
|
|
config_mounts=[],
|
|
environment={},
|
|
)
|
|
assert backend._docker_server_is_desktop() is False
|
|
finally:
|
|
_docker_server_is_desktop.cache_clear()
|
|
|
|
assert seen, "expected the docker detection path to shell out"
|
|
for kwargs in seen:
|
|
assert kwargs.get("encoding") == "utf-8", kwargs
|
|
assert kwargs.get("errors") == "replace", kwargs
|
|
|
|
|
|
def test_every_text_mode_subprocess_call_pins_utf8():
|
|
"""Check each text-mode subprocess call's encoding and error handling.
|
|
|
|
A text-mode call without ``encoding=`` decodes with the platform's
|
|
preferred encoding, which loses output entirely on non-UTF-8 locales.
|
|
``errors="replace"`` also keeps malformed bytes from discarding a stream.
|
|
"""
|
|
source = Path(local_backend_module.__file__).read_text(encoding="utf-8")
|
|
calls = [node for node in ast.walk(ast.parse(source)) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess" and node.func.attr == "run"]
|
|
assert calls, "expected subprocess.run calls in the local backend"
|
|
for call in calls:
|
|
keywords = {keyword.arg: keyword.value for keyword in call.keywords}
|
|
text_mode = keywords.get("text")
|
|
if not isinstance(text_mode, ast.Constant) or text_mode.value is not True:
|
|
continue
|
|
for name, expected in (("encoding", "utf-8"), ("errors", "replace")):
|
|
value = keywords.get(name)
|
|
assert isinstance(value, ast.Constant) and value.value == expected, f"line {call.lineno}: text-mode subprocess.run must pass {name}={expected!r}"
|