"""Regression tests for docker sandbox mode detection logic.""" from __future__ import annotations import os import re import shlex import shutil import subprocess import tempfile from pathlib import Path import pytest import yaml from support.shell import find_script_bash REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT_PATH = REPO_ROOT / "scripts" / "docker.sh" BASH_EXECUTABLE = find_script_bash() # These fixtures are YAML source text, not Python values: quoting must remain # observable (e.g. null is empty, but "null" is a nonempty string). PROVISIONER_VALUES = [ pytest.param(None, "aio", id="omitted"), pytest.param('""', "aio", id="double-quoted-empty"), pytest.param("''", "aio", id="single-quoted-empty"), pytest.param("null", "aio", id="null"), pytest.param("Null", "aio", id="title-case-null"), pytest.param("NULL", "aio", id="upper-case-null"), pytest.param("~", "aio", id="tilde"), pytest.param("", "aio", id="blank"), pytest.param(" ", "aio", id="whitespace"), pytest.param("# not configured", "aio", id="comment-only"), pytest.param('"" # not configured', "aio", id="double-empty-comment"), pytest.param("'' # not configured", "aio", id="single-empty-comment"), pytest.param("null # not configured", "aio", id="null-comment"), pytest.param("~ # not configured", "aio", id="tilde-comment"), pytest.param("http://provisioner:8002", "provisioner", id="url"), pytest.param('"http://provisioner:8002" # configured', "provisioner", id="quoted-url-comment"), pytest.param('"null"', "provisioner", id="quoted-null"), pytest.param("'null'", "provisioner", id="single-quoted-null"), pytest.param('"~"', "provisioner", id="double-quoted-tilde"), pytest.param("'~'", "provisioner", id="quoted-tilde"), pytest.param('"null" # literal', "provisioner", id="quoted-null-comment"), pytest.param('" # not a comment"', "provisioner", id="quoted-hash"), pytest.param('" "', "provisioner", id="quoted-space"), pytest.param("nullish", "provisioner", id="nonempty-string"), ] def _sandbox_config(value: str | None, provider: str = "deerflow.community.aio_sandbox:AioSandboxProvider") -> str: config = f"sandbox:\n use: {provider}\n" return config if value is None else config + f" provisioner_url: {value}\n" def _isolated_shell_env(tmp_path: Path) -> dict[str, str]: # Do not inherit developer credentials, config selectors or shell startup # hooks. Keep only the platform variables needed to find/run Git Bash. env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT") if key in os.environ} env.update(HOME=str(tmp_path), TMPDIR=str(tmp_path), TEMP=str(tmp_path), TMP=str(tmp_path)) return env def _run_detector(script_name: str, tmp_path: Path, config: str | None) -> str: """Execute the real function, never deploy.sh's top-level setup.""" config_path = tmp_path / "config.yaml" if config is not None: config_path.write_text(config, encoding="utf-8") script = (REPO_ROOT / "scripts" / script_name).read_text(encoding="utf-8") match = re.search(r"^detect_sandbox_mode\(\) \{\n.*?^\}", script, re.M | re.S) assert match is not None, f"{script_name} has no detector" command = f""" set -eu SCRIPT_DIR={shlex.quote(str(REPO_ROOT / "scripts"))} REPO_ROOT={shlex.quote(str(REPO_ROOT))} PROJECT_ROOT={shlex.quote(str(tmp_path))} DEER_FLOW_CONFIG_PATH={shlex.quote(str(config_path))} {match.group()} detect_sandbox_mode """ result = subprocess.run([BASH_EXECUTABLE, "-c", command], cwd=tmp_path, env=_isolated_shell_env(tmp_path), capture_output=True, text=True, encoding="utf-8", timeout=10) assert result.returncode == 0, result.stderr return result.stdout.strip() @pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) @pytest.mark.parametrize(("value", "expected"), PROVISIONER_VALUES) def test_provisioner_scalar_detection(script_name, value, expected, tmp_path): assert _run_detector(script_name, tmp_path, _sandbox_config(value)) == expected @pytest.mark.parametrize(("value", "expected"), PROVISIONER_VALUES) def test_provisioner_helper_scalar_detection(value, expected, tmp_path): config_path = tmp_path / "synthetic config.yaml" config_path.write_text(_sandbox_config(value), encoding="utf-8") result = subprocess.run( [BASH_EXECUTABLE, str(REPO_ROOT / "scripts" / "sandbox-mode.sh"), str(config_path)], cwd=tmp_path, env=_isolated_shell_env(tmp_path), capture_output=True, text=True, encoding="utf-8", timeout=10, ) assert result.returncode == 0, result.stderr assert result.stdout.strip() == expected @pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) @pytest.mark.parametrize( "config", [ None, _sandbox_config("null", "deerflow.sandbox.local:LocalSandboxProvider"), _sandbox_config("http://provisioner:8002", "deerflow.sandbox.local:LocalSandboxProvider"), _sandbox_config("http://provisioner:8002", "custom:UnknownProvider"), ], ) def test_provisioner_detection_local_fallback(script_name, config, tmp_path): assert _run_detector(script_name, tmp_path, config) == "local" @pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) @pytest.mark.parametrize( "config", [ _sandbox_config(None) + " # provisioner_url: http://ignored:8002\n", _sandbox_config(None) + "other:\n provisioner_url: http://ignored:8002\n", "other:\n provisioner_url: http://ignored:8002\n" + _sandbox_config("null"), _sandbox_config("'' # empty\t").replace("\n", "\r\n"), ], ids=["commented-key", "following-section", "preceding-section", "crlf"], ) def test_provisioner_detection_preserves_section_boundaries(script_name, config, tmp_path): assert _run_detector(script_name, tmp_path, config) == "aio" @pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) @pytest.mark.parametrize(("value", "expected"), [*PROVISIONER_VALUES, pytest.param(None, "local", id="local-provider")]) def test_provisioner_start_selects_services_and_overlay(script_name, value, expected, tmp_path): """Run both launchers in a synthetic checkout; all external commands are doubles.""" checkout = tmp_path / "synthetic checkout" shutil.copytree(REPO_ROOT / "scripts", checkout / "scripts") docker_dir = checkout / "docker" docker_dir.mkdir() for filename in ("docker-compose-dev.yaml", "docker-compose.yaml", "docker-compose.dood.yaml"): (docker_dir / filename).write_text("services: {}\n", encoding="utf-8") (checkout / "frontend").mkdir() for filename in (".env", "frontend/.env"): (checkout / filename).write_text("# synthetic, no credentials\n", encoding="utf-8") extensions = checkout / "extensions_config.json" extensions.write_text("{}\n", encoding="utf-8") config = _sandbox_config(value, "deerflow.sandbox.local:LocalSandboxProvider") if expected == "local" else _sandbox_config(value) root_config = checkout / "config.yaml" selected_config = checkout / "selected config.yaml" # Dev must keep using PROJECT_ROOT/config.yaml; prod must honor its selector. decoy = _sandbox_config("http://decoy:8002") if expected != "provisioner" else _sandbox_config(None) root_config.write_text(config if script_name == "docker.sh" else decoy, encoding="utf-8") selected_config.write_text(config if script_name == "deploy.sh" else decoy, encoding="utf-8") capture = tmp_path / "compose-args" shell_env = tmp_path / "stubs.sh" shell_env.write_text( """docker() { if [ "$*" = 'compose version --short' ]; then echo '2.24.0'; return; fi if [ "$*" = info ]; then return 0; fi if [ "$1" = compose ]; then printf '%s\\n' "$@" >> "$CAPTURE_COMPOSE_ARGS" return 0 fi echo "Unexpected Docker call: $*" >&2; return 97 } # Exercise the existing Windows socket preflight without requiring a host socket. uname() { echo MINGW64_NT; } python() { return 97; } python3() { return 97; } uv() { return 97; } pnpm() { return 97; } kubectl() { return 97; } openssl() { return 97; } """, encoding="utf-8", ) env = _isolated_shell_env(tmp_path) env.update( BASH_ENV=str(shell_env), CAPTURE_COMPOSE_ARGS=str(capture), DEER_FLOW_HOME=str(tmp_path / "runtime"), DEER_FLOW_CONFIG_PATH=str(selected_config), DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(extensions), BETTER_AUTH_SECRET="synthetic-test-secret", DEER_FLOW_INTERNAL_AUTH_TOKEN="synthetic-test-token", DEER_FLOW_CREDENTIALS_KEY="synthetic-test-key", UV_EXTRAS="redis", ) result = subprocess.run([BASH_EXECUTABLE, str(checkout / "scripts" / script_name), "start"], cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8", timeout=15) assert result.returncode == 0, result.stdout + result.stderr args = capture.read_text(encoding="utf-8").splitlines() assert args.count("up") == 1, args assert ("provisioner" in args) == (expected == "provisioner"), args files = [Path(args[index + 1]).name for index, arg in enumerate(args) if arg == "-f"] base = "docker-compose-dev.yaml" if script_name == "docker.sh" else "docker-compose.yaml" assert files == [base] + (["docker-compose.dood.yaml"] if expected == "aio" else []) assert root_config.read_text(encoding="utf-8") == (config if script_name == "docker.sh" else decoy) assert selected_config.read_text(encoding="utf-8") == (config if script_name == "deploy.sh" else decoy) assert not any((tmp_path / "runtime").glob(".*secret*")) assert not (tmp_path / "runtime" / ".internal-auth-token").exists() assert not (tmp_path / "runtime" / ".credentials_key").exists() if BASH_EXECUTABLE is None: pytestmark = pytest.mark.skip(reason="Git Bash is required for docker.sh detection tests") def _detect_mode_with_config(config_content: str) -> str: """Write config content into a temp project root and execute detect_sandbox_mode.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) (tmp_root / "config.yaml").write_text(config_content, encoding="utf-8") command = f"source '{SCRIPT_PATH}' && PROJECT_ROOT='{tmp_root}' && detect_sandbox_mode" output = subprocess.check_output( [BASH_EXECUTABLE, "-lc", command], text=True, encoding="utf-8", ).strip() return output def test_detect_mode_defaults_to_local_when_config_missing(): """No config file should default to local mode.""" with tempfile.TemporaryDirectory() as tmpdir: command = f"source '{SCRIPT_PATH}' && PROJECT_ROOT='{tmpdir}' && detect_sandbox_mode" output = subprocess.check_output( [BASH_EXECUTABLE, "-lc", command], text=True, encoding="utf-8", ).strip() assert output == "local" def test_detect_mode_local_provider(): """Local sandbox provider should map to local mode.""" config = """ sandbox: use: deerflow.sandbox.local:LocalSandboxProvider """.strip() assert _detect_mode_with_config(config) == "local" def test_detect_mode_aio_without_provisioner_url(): """AIO sandbox without provisioner_url should map to aio mode.""" config = """ sandbox: use: deerflow.community.aio_sandbox:AioSandboxProvider """.strip() assert _detect_mode_with_config(config) == "aio" def test_detect_mode_provisioner_with_url(): """AIO sandbox with provisioner_url should map to provisioner mode.""" config = """ sandbox: use: deerflow.community.aio_sandbox:AioSandboxProvider provisioner_url: http://provisioner:8002 """.strip() assert _detect_mode_with_config(config) == "provisioner" def test_detect_mode_ignores_commented_provisioner_url(): """Commented provisioner_url should not activate provisioner mode.""" config = """ sandbox: use: deerflow.community.aio_sandbox:AioSandboxProvider # provisioner_url: http://provisioner:8002 """.strip() assert _detect_mode_with_config(config) == "aio" def test_detect_mode_unknown_provider_falls_back_to_local(): """Unknown sandbox provider should default to local mode.""" config = """ sandbox: use: custom.module:UnknownProvider """.strip() assert _detect_mode_with_config(config) == "local" def test_dev_compose_keeps_provisioner_behind_opt_in_profile(): """A direct `docker compose up` must not start the Kubernetes provisioner.""" compose_path = REPO_ROOT / "docker" / "docker-compose-dev.yaml" compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) assert compose["services"]["provisioner"]["profiles"] == ["provisioner"] def _seed_compose_file(tmp_root: Path) -> None: """Give require_compose_file the file it validates.""" (tmp_root / "docker-compose-dev.yaml").write_text("services: {}\n", encoding="utf-8") def _seed_env_examples(tmp_root: Path) -> None: """Provide the templates ensure_env_files copies from.""" (tmp_root / ".env.example").write_text("# test\n", encoding="utf-8") frontend = tmp_root / "frontend" frontend.mkdir(exist_ok=True) (frontend / ".env.example").write_text("# test\n", encoding="utf-8") def _run_docker_sh(tmp_root: Path, body: str) -> None: """Run docker.sh against a temp checkout, stubbing the real Compose version probe. Keep SCRIPT_DIR at the real scripts/ directory so stop's cleanup-containers.sh path still resolves; only PROJECT_ROOT / DOCKER_DIR are redirected. """ command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' require_compose_version() {{ :; }} {body} """ subprocess.check_call([BASH_EXECUTABLE, "-lc", command]) @pytest.mark.parametrize("docker_command", ["logs --gateway", "stop", "restart"]) def test_compose_commands_set_deer_flow_root_before_compose(docker_command): """Read-only compose commands should resolve mounts from the repository root.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _run_docker_sh( tmp_root, f""" COMPOSE_CMD=capture_compose capture_compose() {{ test "${{DEER_FLOW_ROOT:-}}" = "$PROJECT_ROOT"; }} unset DEER_FLOW_ROOT {docker_command} """, ) @pytest.mark.parametrize("docker_command", ["logs --gateway", "stop", "restart"]) def test_read_only_commands_do_not_create_env_files(docker_command): """Only start may write configuration; logs/stop/restart must leave a checkout alone.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _seed_env_examples(tmp_root) _run_docker_sh(tmp_root, f"COMPOSE_CMD=true\n{docker_command}") assert not (tmp_root / ".env").exists(), f"{docker_command} created .env" assert not (tmp_root / "frontend" / ".env").exists(), f"{docker_command} created frontend/.env" @pytest.mark.parametrize("docker_command", ["logs --gateway", "stop", "restart"]) def test_read_only_commands_run_without_env_examples(docker_command): """A missing .env.example must never block stopping or inspecting containers.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _run_docker_sh(tmp_root, f"COMPOSE_CMD=true\n{docker_command}") def test_ensure_env_files_copies_from_examples(): """start's env-file step should create .env files from their examples when missing.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_env_examples(tmp_root) command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' ensure_env_files """ subprocess.check_call([BASH_EXECUTABLE, "-lc", command]) assert (tmp_root / ".env").is_file() assert (tmp_root / "frontend" / ".env").is_file() assert (tmp_root / ".env").read_text(encoding="utf-8") == "# test\n" assert (tmp_root / "frontend" / ".env").read_text(encoding="utf-8") == "# test\n" def test_ensure_env_files_leaves_existing_env_untouched(): """ensure_env_files must not overwrite an already-present .env.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_env_examples(tmp_root) (tmp_root / ".env").write_text("KEEP=me\n", encoding="utf-8") frontend = tmp_root / "frontend" (frontend / ".env").write_text("KEEP=frontend\n", encoding="utf-8") command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' ensure_env_files """ subprocess.check_call([BASH_EXECUTABLE, "-lc", command]) assert (tmp_root / ".env").read_text(encoding="utf-8") == "KEEP=me\n" assert (frontend / ".env").read_text(encoding="utf-8") == "KEEP=frontend\n" @pytest.mark.parametrize( ("reported_version", "expected_returncode"), [ ("2.23.3", 1), ("2.5.0", 1), ("2.24.0", 0), ("v2.40.2-desktop.1", 0), ("3.0.1", 0), ("", 0), # undetectable: warn, but do not block ], ) def test_require_compose_version_enforces_minimum(reported_version, expected_returncode): """Old clients get our actionable message instead of a raw Compose parser error.""" # Stub both binaries so an empty plugin probe cannot fall through to the # real hyphenated docker-compose installed on the developer machine. command = f""" source '{SCRIPT_PATH}' docker() {{ echo '{reported_version}'; }} docker-compose() {{ echo '{reported_version}'; }} require_compose_version """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == expected_returncode, result.stdout + result.stderr if expected_returncode != 0: assert "too old" in result.stdout assert "docs.docker.com/compose/install" in result.stdout def test_require_compose_version_falls_back_to_hyphenated_binary(): """Plugin missing + docker-compose 2.24: version check passes and stop uses that binary. Regression for the half-fallback where require_compose_version accepted docker-compose but COMPOSE_CMD stayed hardcoded to `docker compose`. """ with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) marker = tmp_root / "hyphenated_invoke.txt" command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' docker() {{ if [ "$1" = compose ]; then echo "docker: unknown command" >&2 return 1 fi command docker "$@" }} docker-compose() {{ if [ "$1" = version ]; then echo '2.24.0' return 0 fi # Real wrapper ops (down/logs/...) must hit this binary, not `docker compose`. printf '%s\n' "$*" > '{marker}' }} unset DEER_FLOW_ROOT stop """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 0, result.stdout + result.stderr assert marker.is_file(), "stop never invoked docker-compose for the compose operation" assert "down" in marker.read_text(encoding="utf-8") def test_require_compose_version_rejects_old_hyphenated_binary(): """An old docker-compose binary must still fail the floor check.""" command = f""" source '{SCRIPT_PATH}' docker() {{ if [ "$1" = compose ]; then return 1 fi command docker "$@" }} docker-compose() {{ echo '2.23.3'; }} require_compose_version """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 1, result.stdout + result.stderr assert "too old" in result.stdout @pytest.mark.parametrize("args", ["logs --prod", "logs --prod --gateway"]) def test_logs_prod_targets_production_stack(args): """`logs --prod` must tail the stack deploy.sh started, not the dev project. `make up` runs scripts/deploy.sh (project `deer-flow`, docker-compose.yaml) while the dev default is project `deer-flow-dev`, so `make docker-logs` after `make up` printed nothing (#5529). The production entry point must target the same project and interpolate the same .env. Compose detection is NOT stubbed here: it rebuilds COMPOSE_CMD, and the appended `--env-file` must survive that rebuild (#5538 review). """ with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) (tmp_root / "docker-compose.yaml").write_text("services: {}\n", encoding="utf-8") (tmp_root / ".env").write_text("KEEP=me\n", encoding="utf-8") marker = tmp_root / "prod_invoke.txt" command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' docker() {{ if [ "$1" = compose ] && [ "$2" = version ]; then echo '2.41.0' return 0 fi if [ "$1" = compose ]; then printf '%s\\n' "$*" "DEER_FLOW_HOME=${{DEER_FLOW_HOME:-unset}}" > '{marker}' return 0 fi command docker "$@" }} unset DEER_FLOW_ROOT DEER_FLOW_HOME DEER_FLOW_CONFIG_PATH unset DEER_FLOW_EXTENSIONS_CONFIG_PATH DEER_FLOW_REPO_ROOT unset BETTER_AUTH_SECRET DEER_FLOW_INTERNAL_AUTH_TOKEN {args} """ subprocess.check_call([BASH_EXECUTABLE, "-lc", command]) recorded = marker.read_text(encoding="utf-8") assert "-p deer-flow " in recorded, recorded assert "-f docker-compose.yaml" in recorded, recorded assert "--env-file ../.env" in recorded, recorded assert "logs" in recorded, recorded # deploy.sh exports interpolation defaults before every compose call; # `logs --prod` must too, or the volume specs fail to parse. assert "DEER_FLOW_HOME=" in recorded, recorded assert "DEER_FLOW_HOME=unset" not in recorded, recorded def test_logs_without_dev_containers_hints_at_prod_logs(): """Silent empty output is the #5529 report: point at the production entry point.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' require_compose_version() {{ :; }} COMPOSE_CMD=true unset DEER_FLOW_ROOT logs """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 0, result.stdout + result.stderr assert "make prod-logs" in result.stdout def test_aio_dood_socket_preflight_allows_windows_when_docker_reachable(): """Windows Git Bash without /var/run/docker.sock proceeds when Docker daemon is reachable.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _seed_env_examples(tmp_root) (tmp_root / "config.yaml").write_text( "sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n", encoding="utf-8", ) command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' require_compose_version() {{ :; }} uname() {{ echo 'MINGW64_NT-10.0'; }} docker() {{ if [ "$1" = info ]; then return 0 fi return 0 }} DEER_FLOW_DOCKER_SOCKET='/var/run/docker.sock' COMPOSE_CMD=echo start """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 0, result.stdout + result.stderr assert "docker-compose.dood.yaml" in result.stdout def test_aio_dood_socket_preflight_rejects_missing_socket_on_posix(): """POSIX hosts without a physical socket file must fail fast.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _seed_env_examples(tmp_root) (tmp_root / "config.yaml").write_text( "sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n", encoding="utf-8", ) command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' require_compose_version() {{ :; }} uname() {{ echo 'Linux'; }} DEER_FLOW_DOCKER_SOCKET='/nonexistent/docker.sock' COMPOSE_CMD=echo start """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 1, result.stdout + result.stderr assert "Docker socket not found" in result.stdout @pytest.mark.parametrize( ("sandbox_provider", "expected_profile"), [ ("deerflow.sandbox.local:LocalSandboxProvider", False), ( "deerflow.community.aio_sandbox:AioSandboxProvider\n provisioner_url: http://provisioner:8002", True, ), ], ) def test_start_toggles_provisioner_profile(sandbox_provider, expected_profile): """make docker-start enables the profile only for provisioner mode.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _seed_env_examples(tmp_root) (tmp_root / "config.yaml").write_text( f"sandbox:\n use: {sandbox_provider}\n", encoding="utf-8", ) marker = tmp_root / "start_invoke.txt" _run_docker_sh( tmp_root, f""" COMPOSE_CMD=capture_compose capture_compose() {{ printf '%s\\n' "$*" > '{marker}'; }} start """, ) recorded = marker.read_text(encoding="utf-8") assert ("--profile provisioner" in recorded) is expected_profile def test_stop_includes_profile_gated_provisioner(): """make docker-stop must remove a provisioner started with the profile.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) marker = tmp_root / "stop_invoke.txt" _run_docker_sh( tmp_root, f""" COMPOSE_CMD=capture_compose capture_compose() {{ printf '%s\\n' "$*" > '{marker}'; }} stop """, ) assert "--profile provisioner down" in marker.read_text(encoding="utf-8") def test_aio_dood_socket_preflight_rejects_missing_custom_socket_on_windows(): """Windows Git Bash must fail if a custom non-existent socket path is specified.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _seed_env_examples(tmp_root) (tmp_root / "config.yaml").write_text( "sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n", encoding="utf-8", ) command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' require_compose_version() {{ :; }} uname() {{ echo 'MINGW64_NT-10.0'; }} docker() {{ if [ "$1" = info ]; then return 0 fi return 0 }} DEER_FLOW_DOCKER_SOCKET='/nonexistent/docker.sock' COMPOSE_CMD=echo start """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 1, result.stdout + result.stderr assert "Docker socket not found" in result.stdout @pytest.mark.skipif( Path("/var/run/docker.sock").is_socket(), reason="Host has real /var/run/docker.sock", ) def test_aio_dood_socket_preflight_rejects_windows_when_docker_unreachable(): """Windows Git Bash must fail if Docker daemon is not reachable.""" with tempfile.TemporaryDirectory() as tmpdir: tmp_root = Path(tmpdir) _seed_compose_file(tmp_root) _seed_env_examples(tmp_root) (tmp_root / "config.yaml").write_text( "sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n", encoding="utf-8", ) command = f""" source '{SCRIPT_PATH}' PROJECT_ROOT='{tmp_root}' DOCKER_DIR='{tmp_root}' require_compose_version() {{ :; }} uname() {{ echo 'MINGW64_NT-10.0'; }} docker() {{ if [ "$1" = info ]; then return 1 fi return 0 }} DEER_FLOW_DOCKER_SOCKET='/var/run/docker.sock' COMPOSE_CMD=echo start """ result = subprocess.run( [BASH_EXECUTABLE, "-lc", command], capture_output=True, text=True, encoding="utf-8", ) assert result.returncode == 1, result.stdout + result.stderr assert "Docker socket not found" in result.stdout def _setup_deploy_worktree(tmp_path: Path) -> Path: worktree = tmp_path / "repo" shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts") shutil.copytree(REPO_ROOT / "docker", worktree / "docker") (worktree / "backend").mkdir() (worktree / "config.yaml").write_text( "sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n", encoding="utf-8", ) (worktree / "extensions_config.json").write_text("{}\n", encoding="utf-8") (worktree / ".env").write_text("TEST=1\n", encoding="utf-8") return worktree def test_aio_deploy_socket_preflight_allows_windows_when_docker_reachable(tmp_path): """deploy.sh on Windows Git Bash proceeds past preflight when Docker daemon is reachable.""" worktree = _setup_deploy_worktree(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() capture_args = tmp_path / "docker_args.txt" docker = bin_dir / "docker" docker.write_text( f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nfor arg in "$@"; do printf "%s\\n" "$arg"; done > "{capture_args}"\nexit 0\n', encoding="utf-8", ) docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env["DEER_FLOW_DOCKER_SOCKET"] = "/var/run/docker.sock" env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stdout + result.stderr assert "docker-compose.dood.yaml" in capture_args.read_text(encoding="utf-8") def test_aio_deploy_socket_preflight_rejects_missing_socket_on_posix(tmp_path): """deploy.sh on POSIX hosts without a physical socket file fails fast.""" worktree = _setup_deploy_worktree(tmp_path) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'Linux'; }\n", encoding="utf-8") env = os.environ.copy() env["BASH_ENV"] = str(bash_env) env["DEER_FLOW_DOCKER_SOCKET"] = "/nonexistent/docker.sock" env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 1, result.stdout + result.stderr assert "Docker socket not found" in result.stdout + result.stderr def test_aio_deploy_socket_preflight_rejects_missing_custom_socket_on_windows(tmp_path): """deploy.sh on Windows Git Bash fails if a custom non-existent socket path is specified.""" worktree = _setup_deploy_worktree(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() docker = bin_dir / "docker" docker.write_text( '#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nexit 0\n', encoding="utf-8", ) docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env["DEER_FLOW_DOCKER_SOCKET"] = "/nonexistent/docker.sock" env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 1, result.stdout + result.stderr assert "Docker socket not found" in result.stdout + result.stderr @pytest.mark.skipif( Path("/var/run/docker.sock").is_socket(), reason="Host has real /var/run/docker.sock", ) def test_aio_deploy_socket_preflight_rejects_windows_when_docker_unreachable(tmp_path): """deploy.sh on Windows Git Bash fails if Docker daemon is not reachable.""" worktree = _setup_deploy_worktree(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() docker = bin_dir / "docker" docker.write_text('#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 1; fi\nexit 0\n', encoding="utf-8") docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env["DEER_FLOW_DOCKER_SOCKET"] = "/var/run/docker.sock" env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 1, result.stdout + result.stderr assert "Docker socket not found" in result.stdout + result.stderr def test_aio_deploy_socket_unsets_default_on_windows(tmp_path): """deploy.sh on Windows Git Bash unsets default /var/run/docker.sock before calling Compose.""" worktree = _setup_deploy_worktree(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() capture_socket_env = tmp_path / "docker_socket_env.txt" docker = bin_dir / "docker" docker.write_text( f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n', encoding="utf-8", ) docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env["DEER_FLOW_DOCKER_SOCKET"] = "/var/run/docker.sock" env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stdout + result.stderr assert capture_socket_env.read_text(encoding="utf-8") == "" def test_aio_deploy_socket_preserves_unset_default_on_windows(tmp_path): """deploy.sh does not export default /var/run/docker.sock when unset initially.""" worktree = _setup_deploy_worktree(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() capture_socket_env = tmp_path / "docker_socket_env.txt" docker = bin_dir / "docker" docker.write_text( f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n', encoding="utf-8", ) docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env.pop("DEER_FLOW_DOCKER_SOCKET", None) env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stdout + result.stderr assert capture_socket_env.read_text(encoding="utf-8") == "" def test_aio_deploy_socket_preserves_custom_socket_on_windows(tmp_path): """deploy.sh preserves and exports custom DEER_FLOW_DOCKER_SOCKET on Windows.""" worktree = _setup_deploy_worktree(tmp_path) bin_dir = tmp_path / "bin" bin_dir.mkdir() capture_socket_env = tmp_path / "docker_socket_env.txt" docker = bin_dir / "docker" docker.write_text( f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n', encoding="utf-8", ) docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text( 'uname() { echo "MINGW64_NT-10.0"; }\n[() {\n if [[ "$1" == "!" && "$2" == "-S" ]]; then\n return 1\n fi\n builtin [ "$@"\n}\n', encoding="utf-8", ) env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env["DEER_FLOW_DOCKER_SOCKET"] = "/custom/docker.sock" env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stdout + result.stderr assert capture_socket_env.read_text(encoding="utf-8") == "/custom/docker.sock" def test_aio_deploy_socket_reads_from_dotenv(tmp_path): """deploy.sh resolves DEER_FLOW_DOCKER_SOCKET from .env when unset in shell environment.""" worktree = _setup_deploy_worktree(tmp_path) (worktree / ".env").write_text("DEER_FLOW_DOCKER_SOCKET=/var/run/docker.sock\n", encoding="utf-8") bin_dir = tmp_path / "bin" bin_dir.mkdir() capture_socket_env = tmp_path / "docker_socket_env.txt" docker = bin_dir / "docker" docker.write_text( f'#!/usr/bin/env sh\nif [ "$1" = "info" ]; then exit 0; fi\nprintf "%s" "$DEER_FLOW_DOCKER_SOCKET" > "{capture_socket_env}"\nexit 0\n', encoding="utf-8", ) docker.chmod(0o755) bash_env = tmp_path / "env.sh" bash_env.write_text("uname() { echo 'MINGW64_NT-10.0'; }\n", encoding="utf-8") env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["BASH_ENV"] = str(bash_env) env.pop("DEER_FLOW_DOCKER_SOCKET", None) env["BETTER_AUTH_SECRET"] = "test-secret" env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-token" env["DEER_FLOW_CREDENTIALS_KEY"] = "test-credentials-key" env["UV_EXTRAS"] = "redis" result = subprocess.run( [BASH_EXECUTABLE, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stdout + result.stderr assert capture_socket_env.read_text(encoding="utf-8") == ""