diff --git a/backend/tests/test_docker_sandbox_mode_detection.py b/backend/tests/test_docker_sandbox_mode_detection.py index 7dfbd6898b..6333b40f32 100644 --- a/backend/tests/test_docker_sandbox_mode_detection.py +++ b/backend/tests/test_docker_sandbox_mode_detection.py @@ -128,8 +128,9 @@ def test_provisioner_detection_local_fallback(script_name, config, tmp_path): _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"), + "--- # document start\n sandbox: # sandbox config\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n", ], - ids=["commented-key", "following-section", "preceding-section", "crlf"], + ids=["commented-key", "following-section", "preceding-section", "crlf", "indented-commented-heading"], ) def test_provisioner_detection_preserves_section_boundaries(script_name, config, tmp_path): assert _run_detector(script_name, tmp_path, config) == "aio" @@ -138,7 +139,74 @@ def test_provisioner_detection_preserves_section_boundaries(script_name, config, @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.""" + config = _sandbox_config(value, "deerflow.sandbox.local:LocalSandboxProvider") if expected == "local" else _sandbox_config(value) + _assert_launcher_selects_sandbox_services(script_name, config, expected, tmp_path) + + +@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) +@pytest.mark.parametrize(("value", "expected"), [(None, "aio"), ("http://provisioner:8002", "provisioner")]) +def test_start_accepts_comment_on_sandbox_heading(script_name, value, expected, tmp_path): + config = _sandbox_config(value).replace("sandbox:\n", "sandbox: # sandbox config\n", 1) + _assert_launcher_selects_sandbox_services(script_name, config, expected, tmp_path) + + +@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) +@pytest.mark.parametrize( + ("config", "expected"), + [ + pytest.param( + "custom:\n sandbox:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\nsandbox: # actual sandbox\n use: deerflow.sandbox.local:LocalSandboxProvider\n", + "local", + id="nested-sandbox", + ), + pytest.param( + "sandbox: # sandbox config\n custom:\n use: deerflow.community.aio_sandbox:AioSandboxProvider\n use: deerflow.sandbox.local:LocalSandboxProvider\n", + "local", + id="nested-use", + ), + pytest.param( + _sandbox_config(None) + " options:\n provisioner_url: http://decoy:8002\n", + "aio", + id="nested-provisioner-url", + ), + ], +) +def test_start_ignores_nested_sandbox_keys(script_name, config, expected, tmp_path): + _assert_launcher_selects_sandbox_services(script_name, config, expected, tmp_path) + + +@pytest.mark.parametrize("script_name", ["docker.sh", "deploy.sh"]) +@pytest.mark.parametrize( + ("config", "expected"), + [ + pytest.param( + "sandbox: # settings\n use: custom:OtherProvider # deerflow.community.aio_sandbox:AioSandboxProvider\n", + "local", + id="provider-in-comment", + ), + pytest.param( + "sandbox: # settings\n use: 'custom:OtherProvider # deerflow.community.aio_sandbox:AioSandboxProvider'\n", + "local", + id="provider-in-quoted-text", + ), + pytest.param( + 'sandbox: # settings\n use: "deerflow.community.aio_sandbox:AioSandboxProvider" # deerflow.sandbox.local:LocalSandboxProvider\n provisioner_url: http://provisioner:8002\n', + "provisioner", + id="four-space-quoted-provider-with-comment", + ), + pytest.param( + "sandbox: # settings\n use: 'deerflow.community.aio_sandbox:AioSandboxProvider' # local AIO\n", + "aio", + id="single-quoted-provider", + ), + ], +) +def test_start_uses_the_provider_scalar(script_name, config, expected, tmp_path): + _assert_launcher_selects_sandbox_services(script_name, config, expected, tmp_path) + + +def _assert_launcher_selects_sandbox_services(script_name, config, expected, tmp_path): + """Run the launcher in a synthetic checkout with Docker stubbed.""" checkout = tmp_path / "synthetic checkout" shutil.copytree(REPO_ROOT / "scripts", checkout / "scripts") docker_dir = checkout / "docker" @@ -150,7 +218,6 @@ def test_provisioner_start_selects_services_and_overlay(script_name, value, expe (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. @@ -196,7 +263,8 @@ openssl() { return 97; } 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 + services_and_options = args[args.index("up") + 1 :] + assert ("provisioner" in services_and_options) == (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 []) diff --git a/docs/guide/sandbox.md b/docs/guide/sandbox.md index 8ea042230a..dea7e2fbfa 100644 --- a/docs/guide/sandbox.md +++ b/docs/guide/sandbox.md @@ -30,7 +30,8 @@ error even when the referenced sandbox is cached. When host Bash is enabled for Local Execution, DeerFlow starts OS detection with `uname -s`, then uses `sw_vers` on Darwin. On Linux, it reads host system files such as `/etc/os-release` only when the active sandbox policy permits it. Host filesystem path checks still apply; after a blocked path, the agent is directed to use a permitted command-only probe or virtual path instead of repeating the rejected command. For Docker development and production, service startup follows the configured -sandbox mode. With `AioSandboxProvider`, an omitted `provisioner_url`, an empty +sandbox mode, including when the `sandbox:` header or `use` field has a trailing +YAML comment. With `AioSandboxProvider`, an omitted `provisioner_url`, an empty string (`""` or `''`), or YAML null (`null`, `~`, or a blank value), including trailing comments, selects `aio`: `provisioner` is not started and the Docker socket overlay is enabled. This grants the gateway host root-equivalent Docker diff --git a/scripts/sandbox-mode.sh b/scripts/sandbox-mode.sh index 30694c75b6..e76c506363 100755 --- a/scripts/sandbox-mode.sh +++ b/scripts/sandbox-mode.sh @@ -19,22 +19,33 @@ awk ' sub(/[[:space:]]+$/, "", value) return value == "" || value ~ /^#/ || value ~ /^(null|Null|NULL|~|""|\047\047)([[:space:]]+#.*)?$/ } - /^[[:space:]]*sandbox:[[:space:]]*$/ { in_sandbox=1; next } - in_sandbox && /^[^[:space:]#]/ { in_sandbox=0 } - in_sandbox && !seen_use && /^[[:space:]]*use:[[:space:]]*/ { + /^[[:space:]]*(#|$)/ || /^(---|\.\.\.)([[:space:]]|$)/ || /^%/ { next } + { + indent = match($0, /[^[:space:]]/) + if (!root_indent) root_indent = indent + } + indent == root_indent { + in_sandbox = $0 ~ /^[[:space:]]*sandbox:([[:blank:]]+#.*)?[[:space:]]*$/ + child_indent = 0 + next + } + in_sandbox && !child_indent { child_indent = indent } + in_sandbox && indent == child_indent && !seen_use && /^[[:space:]]*use:[[:space:]]*/ { sandbox_use=$0 sub(/^[[:space:]]*use:[[:space:]]*/, "", sandbox_use) + # Keep the exact match below so truncated quoted values fall back to local. + sub(/[[:blank:]]+#.*$/, "", sandbox_use) + sub(/[[:space:]]+$/, "", sandbox_use) seen_use=1 } - in_sandbox && !seen_url && /^[[:space:]]*provisioner_url:[[:space:]]*/ { + in_sandbox && indent == child_indent && !seen_url && /^[[:space:]]*provisioner_url:[[:space:]]*/ { provisioner_url=$0 sub(/^[[:space:]]*provisioner_url:[[:space:]]*/, "", provisioner_url) seen_url=1 } END { - if (index(sandbox_use, "deerflow.sandbox.local:LocalSandboxProvider")) { - print "local" - } else if (index(sandbox_use, "deerflow.community.aio_sandbox:AioSandboxProvider")) { + aio_provider = "deerflow.community.aio_sandbox:AioSandboxProvider" + if (sandbox_use == aio_provider || sandbox_use == "\"" aio_provider "\"" || sandbox_use == "\047" aio_provider "\047") { print empty_scalar(provisioner_url) ? "aio" : "provisioner" } else { print "local"