From 8a3986730f4367d28a100335fa8823f9ab2d673e Mon Sep 17 00:00:00 2001 From: X77 <1494159925@qq.com> Date: Thu, 8 Oct 2026 20:20:22 +0800 Subject: [PATCH] fix(deploy): fail fast when DEER_FLOW_HOME is not writable (dev stack root ownership) (#6488) * fix(deploy): fail fast when DEER_FLOW_HOME is not writable `make docker-start` bind-mounts the host `backend/` directory into the gateway container, whose process runs as root, so any state it creates under `backend/.deer-flow` ends up owned by root on the host. A later `make up` runs `scripts/deploy.sh` as the invoking user and only fails deep into the run with a bare "Permission denied" while persisting generated secrets (deploy.sh:234). Check writability right after `mkdir -p "$DEER_FLOW_HOME"` and, when it fails, print the exact `chown` recovery command instead of letting the run get halfway through. `make down` is excluded so stopping the stack is never blocked. Fixes #6462 * test(deploy): cover runtime home writability preflight * fix(deploy): diagnose persisted secret permissions and preserve teardown --------- Co-authored-by: Willem Jiang --- README.md | 6 + backend/tests/AGENTS.md | 11 + backend/tests/test_deploy_home_writability.py | 237 ++++++++++++++++++ scripts/AGENTS.md | 8 + scripts/deploy.sh | 31 ++- 5 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_deploy_home_writability.py diff --git a/README.md b/README.md index 6c87379958..b1278da62f 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,12 @@ exits non-zero and prints the container status plus recent Gateway logs. The production image starts from its already-built environment and never resolves or installs Python dependencies at container startup. +If `make up` reports an unwritable runtime home or an unreadable persisted secret +after `make docker-start`, run the printed `sudo chown -R : ''` +recovery command and retry. The check honors secret overrides from the shell or +`.env`, accepts readable read-only secret files, and leaves `make down` available +without reading or generating secrets. + For persistent deployments, configure `database.backend` as `sqlite` or `postgres`. The selected backend is shared by the LangGraph checkpointer, LangGraph Store, and DeerFlow application data. The deprecated `checkpointer` diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index 0ff5f021d1..cdf2c9dc08 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -188,3 +188,14 @@ assignment. Use offline transports. Detection is not behavioral defense; do not the final model input from an isolated hook test. `test_jev_screening_policy.py` uses the host descriptor builder to pin policy identity; hash endpoint/prompt text and never project credential values. + +## Deploy home permission tests + +`test_deploy_home_writability.py` covers the production deploy permission +preflight. It runs the complete script with a recording Docker stub and +isolated runtime paths. Permission cases remove directory write bits or secret +file read bits and restore them during teardown; they skip on Windows, as root, +or when the filesystem does not enforce those bits. Cover both persisted +secrets, shell/Compose dotenv overrides, readable read-only secrets, and teardown +without secret exports. `down` must not probe, read, or generate secrets. +Writable-directory cases remain portable. diff --git a/backend/tests/test_deploy_home_writability.py b/backend/tests/test_deploy_home_writability.py new file mode 100644 index 0000000000..69f4b2c26a --- /dev/null +++ b/backend/tests/test_deploy_home_writability.py @@ -0,0 +1,237 @@ +"""Regression coverage for deploy.sh's DEER_FLOW_HOME permission preflight.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from support.shell import find_script_bash + +REPO_ROOT = Path(__file__).resolve().parents[2] +BASH = find_script_bash() +pytestmark = pytest.mark.skipif(BASH is None, reason="repo shell-script tests need Git Bash on Windows") + + +@pytest.fixture +def deploy_fixture(tmp_path: Path): + """Run the real deploy script with isolated state and a recording Docker stub.""" + worktree = tmp_path / "repo" + shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts") + shutil.copytree(REPO_ROOT / "docker", worktree / "docker") + (worktree / "backend").mkdir() + config = worktree / "config.yaml" + config.write_text("sandbox:\n use: deerflow.sandbox:LocalSandboxProvider\n", encoding="utf-8") + extensions = worktree / "extensions_config.json" + extensions.write_text('{"mcpServers":{},"skills":{}}\n', encoding="utf-8") + + # A path containing spaces also checks quoting in the guard and recovery hint. + home = tmp_path / "runtime home" + home.mkdir() + capture = tmp_path / "docker-args.txt" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + docker = bin_dir / "docker" + docker.write_text( + '#!/usr/bin/env sh\nprintf "%s\\n" "$@" >> "$CAPTURE_DOCKER_ARGS"\ncase " $* " in\n *" config "*) printf " DEER_FLOW_PROBE_VALUE: %s\\n" "${PERMISSION_TEST_COMPOSE_SECRET:-\\"\\"}" ;;\nesac\n', + encoding="utf-8", + ) + docker.chmod(0o755) + + env = os.environ.copy() + env.pop("PERMISSION_TEST_COMPOSE_SECRET", None) + env.update( + PATH=f"{bin_dir}{os.pathsep}{env['PATH']}", + DEER_FLOW_HOME=str(home), + DEER_FLOW_CONFIG_PATH=str(config), + DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(extensions), + CAPTURE_DOCKER_ARGS=str(capture), + # Keep the preflight independent of secret generation and optional extras. + BETTER_AUTH_SECRET="test-better-auth-secret", + DEER_FLOW_INTERNAL_AUTH_TOKEN="test-internal-auth-token", + UV_EXTRAS="redis", + ) + return worktree, env, home, capture + + +@pytest.fixture +def unwritable_home(deploy_fixture): + """Reproduce unwritability without chown, restoring permissions on every exit.""" + if os.name != "posix": + pytest.skip("directory write permissions require a POSIX filesystem") + if os.geteuid() == 0: + pytest.skip("root can write directories despite removed write permissions") + + _, _, home, _ = deploy_fixture + original_mode = home.stat().st_mode + home.chmod(0o555) + try: + if os.access(home, os.W_OK): + pytest.skip("filesystem does not enforce directory write permissions") + yield home + finally: + home.chmod(original_mode) + + +@pytest.fixture( + params=[("BETTER_AUTH_SECRET", ".better-auth-secret"), ("DEER_FLOW_INTERNAL_AUTH_TOKEN", ".internal-auth-token")], + ids=["better-auth", "internal-auth"], +) +def persisted_secret(request, deploy_fixture): + _, _, home, _ = deploy_fixture + key, filename = request.param + secret_file = home / filename + secret_file.write_text("persisted-test-secret\n", encoding="utf-8") + return key, secret_file + + +@pytest.fixture +def unreadable_secret(persisted_secret): + if os.name != "posix": + pytest.skip("file read permissions require a POSIX filesystem") + if os.geteuid() == 0: + pytest.skip("root can read files despite removed read permissions") + + _, secret_file = persisted_secret + original_mode = secret_file.stat().st_mode + secret_file.chmod(0o000) + try: + if os.access(secret_file, os.R_OK): + pytest.skip("filesystem does not enforce file read permissions") + yield secret_file + finally: + secret_file.chmod(original_mode) + + +def _run_deploy(deploy_fixture, command: str): + worktree, env, _, _ = deploy_fixture + return subprocess.run( + [BASH, str(worktree / "scripts" / "deploy.sh"), *([command] if command else [])], + cwd=worktree, + env=env, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + +@pytest.mark.parametrize("command", ["", "build", "start"], ids=["up", "build", "start"]) +def test_deploy_rejects_unwritable_home_before_invoking_docker(deploy_fixture, unwritable_home, command): + _, _, _, capture = deploy_fixture + + result = _run_deploy(deploy_fixture, command) + + assert result.returncode == 1 + assert f"{unwritable_home} is not writable by '" in result.stderr + assert "make docker-start" in result.stderr + assert f"sudo chown -R {os.geteuid()}:{os.getegid()} '{unwritable_home}'" in result.stderr + assert not capture.exists(), "Docker must not run when the home preflight fails" + + +@pytest.mark.parametrize( + ("command", "compose_command"), + [("", "up"), ("build", "build"), ("start", "up"), ("down", "down")], + ids=["up", "build", "start", "down"], +) +def test_deploy_allows_writable_home(deploy_fixture, command, compose_command): + _, _, _, capture = deploy_fixture + + result = _run_deploy(deploy_fixture, command) + + assert result.returncode == 0, result.stderr + assert "is not writable" not in result.stderr + assert compose_command in capture.read_text(encoding="utf-8").splitlines() + + +def test_deploy_down_allows_unwritable_home(deploy_fixture, unwritable_home): + _, env, _, capture = deploy_fixture + env.pop("BETTER_AUTH_SECRET") + env.pop("DEER_FLOW_INTERNAL_AUTH_TOKEN") + + result = _run_deploy(deploy_fixture, "down") + + assert result.returncode == 0, result.stderr + assert "is not writable" not in result.stderr + assert capture.read_text(encoding="utf-8").splitlines()[-1] == "down" + assert not list(unwritable_home.iterdir()), "Stopping must not generate secrets" + + +@pytest.mark.parametrize("command", ["", "build", "start"], ids=["up", "build", "start"]) +def test_deploy_rejects_unreadable_persisted_secret_before_starting_compose(deploy_fixture, persisted_secret, unreadable_secret, command): + _, env, home, capture = deploy_fixture + key, _ = persisted_secret + env.pop(key) + + result = _run_deploy(deploy_fixture, command) + + assert result.returncode == 1 + assert f"{unreadable_secret} is not readable by '" in result.stderr + assert "make docker-start" in result.stderr + assert f"sudo chown -R {os.geteuid()}:{os.getegid()} '{home}'" in result.stderr + assert "persisted-test-secret" not in result.stdout + result.stderr + args = capture.read_text(encoding="utf-8").splitlines() + assert "config" in args + assert not {"up", "build"}.intersection(args), "Only the dotenv probe may run before a secret preflight fails" + + +@pytest.mark.parametrize("source", ["shell", "dotenv"]) +def test_deploy_allows_override_of_unreadable_persisted_secret(deploy_fixture, persisted_secret, unreadable_secret, source): + worktree, env, _, capture = deploy_fixture + key, _ = persisted_secret + if source == "dotenv": + env.pop(key) + (worktree / ".env").write_text(f"{key}=override-from-dotenv\n", encoding="utf-8") + env["PERMISSION_TEST_COMPOSE_SECRET"] = "override-from-dotenv" + + result = _run_deploy(deploy_fixture, "build") + + assert result.returncode == 0, result.stderr + assert "is not readable" not in result.stderr + assert unreadable_secret.stat().st_mode & 0o777 == 0 + assert "build" in capture.read_text(encoding="utf-8").splitlines() + if source == "dotenv": + assert f"{key} loaded from {worktree / '.env'}" in result.stdout + + +def test_deploy_allows_readonly_persisted_secret(deploy_fixture, persisted_secret): + if os.name != "posix": + pytest.skip("file write permissions require a POSIX filesystem") + if os.geteuid() == 0: + pytest.skip("root can write files despite removed write permissions") + + _, env, _, capture = deploy_fixture + key, secret_file = persisted_secret + env.pop(key) + original_mode = secret_file.stat().st_mode + secret_file.chmod(0o400) + try: + if os.access(secret_file, os.W_OK): + pytest.skip("filesystem does not enforce file write permissions") + + result = _run_deploy(deploy_fixture, "build") + + assert result.returncode == 0, result.stderr + assert f"{key} loaded from {secret_file}" in result.stdout + assert secret_file.read_text(encoding="utf-8") == "persisted-test-secret\n" + assert secret_file.stat().st_mode & 0o777 == 0o400 + assert "build" in capture.read_text(encoding="utf-8").splitlines() + finally: + secret_file.chmod(original_mode) + + +def test_deploy_down_allows_unreadable_persisted_secret(deploy_fixture, unreadable_secret): + _, env, home, capture = deploy_fixture + env.pop("BETTER_AUTH_SECRET") + env.pop("DEER_FLOW_INTERNAL_AUTH_TOKEN") + + result = _run_deploy(deploy_fixture, "down") + + assert result.returncode == 0, result.stderr + assert "is not readable" not in result.stderr + args = capture.read_text(encoding="utf-8").splitlines() + assert args[-1] == "down" + assert "config" not in args, "Stopping must not resolve secrets" + assert list(home.iterdir()) == [unreadable_secret], "Stopping must not generate secrets" diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 49361815c0..298c2b5240 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -67,6 +67,14 @@ where `make up` replaced the operator's secret with a generated one. its real-Compose cases run against the installed `docker` CLI and against any standalone binaries listed in `DEER_FLOW_TEST_COMPOSE_BINARIES`. +Deployment commands check `DEER_FLOW_HOME` writability before setup and check +persisted secret readability only when shell/Compose dotenv overrides are absent. +Both failures identify the affected path and print the recursive ownership +recovery hint for the runtime home. Existing secrets are only read, so a +readable, read-only file is valid. `down` skips these checks and all secret +resolution/generation so permission damage cannot prevent teardown. Coverage: +`backend/tests/test_deploy_home_writability.py`. + `doctor.py` checks the config file the Gateway would load, not a fixed `/config.yaml`. It mirrors how `serve.sh` hands the two config-location variables to the Gateway: `.env` values for diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ebc67c5d73..0435308b09 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -120,6 +120,25 @@ fi echo -e "${BLUE}DEER_FLOW_HOME=$DEER_FLOW_HOME${NC}" mkdir -p "$DEER_FLOW_HOME" +# ── DEER_FLOW_HOME writability preflight ───────────────────────────────────── + +# `make docker-start` bind-mounts the host `backend/` directory into the gateway +# container, whose process runs as root, so state it creates under +# backend/.deer-flow ends up owned by root on the host. A later `make up` runs +# this script as the invoking user and only fails deep into the run with a bare +# "Permission denied" while persisting generated secrets. Detect it up front and +# print the exact recovery command instead. +fail_home_permission() { + echo -e "${RED}✗ $1 is not $2 by '$(id -un)'.${NC}" >&2 + echo -e "${RED} This usually happens when the dev stack (make docker-start) created it as root.${NC}" >&2 + echo -e "${YELLOW} Recover with: sudo chown -R $(id -u):$(id -g) '$DEER_FLOW_HOME'${NC}" >&2 + exit 1 +} + +if [ "$CMD" != "down" ] && [ ! -w "$DEER_FLOW_HOME" ]; then + fail_home_permission "$DEER_FLOW_HOME" writable +fi + # ── DEER_FLOW_REPO_ROOT (for skills host path in DooD) ─────────────────────── export DEER_FLOW_REPO_ROOT="$REPO_ROOT" @@ -208,10 +227,15 @@ dotenv_provides_secret() { # sessions survive container restarts. _secret_file="$DEER_FLOW_HOME/.better-auth-secret" -if [ -z "$BETTER_AUTH_SECRET" ] && dotenv_provides_secret BETTER_AUTH_SECRET; then +if [ "$CMD" != "down" ] && [ -z "$BETTER_AUTH_SECRET" ] && dotenv_provides_secret BETTER_AUTH_SECRET; then echo -e "${GREEN}✓ BETTER_AUTH_SECRET loaded from $ENV_FILE${NC}" -elif [ -z "$BETTER_AUTH_SECRET" ]; then +elif [ "$CMD" != "down" ] && [ -z "$BETTER_AUTH_SECRET" ]; then if [ -f "$_secret_file" ]; then + # A writable directory can still contain a root-owned, unreadable file. + # Persisted secrets are only read; read-only files and overrides are valid. + if [ ! -r "$_secret_file" ]; then + fail_home_permission "$_secret_file" readable + fi export BETTER_AUTH_SECRET BETTER_AUTH_SECRET="$(cat "$_secret_file")" echo -e "${GREEN}✓ BETTER_AUTH_SECRET loaded from $_secret_file${NC}" @@ -246,6 +270,9 @@ if [ "$CMD" != "down" ] && [ -z "$DEER_FLOW_INTERNAL_AUTH_TOKEN" ] && dotenv_pro echo -e "${GREEN}✓ DEER_FLOW_INTERNAL_AUTH_TOKEN loaded from $ENV_FILE${NC}" elif [ "$CMD" != "down" ] && [ -z "$DEER_FLOW_INTERNAL_AUTH_TOKEN" ]; then if [ -f "$_internal_auth_token_file" ]; then + if [ ! -r "$_internal_auth_token_file" ]; then + fail_home_permission "$_internal_auth_token_file" readable + fi export DEER_FLOW_INTERNAL_AUTH_TOKEN DEER_FLOW_INTERNAL_AUTH_TOKEN="$(cat "$_internal_auth_token_file")" echo -e "${GREEN}✓ DEER_FLOW_INTERNAL_AUTH_TOKEN loaded from $_internal_auth_token_file${NC}"