mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
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:
parent
a02bfe2968
commit
8a3986730f
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
237
backend/tests/test_deploy_home_writability.py
Normal file
237
backend/tests/test_deploy_home_writability.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user