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 <willem.jiang@gmail.com>
This commit is contained in:
X77 2026-10-08 20:20:22 +08:00 committed by GitHub
parent a02bfe2968
commit 8a3986730f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 291 additions and 2 deletions

View File

@ -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 <uid>:<gid> '<home>'`
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`

View File

@ -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.

View File

@ -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"

View File

@ -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
`<checkout>/config.yaml`. It mirrors how `serve.sh` hands the two
config-location variables to the Gateway: `.env` values for

View File

@ -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}"