From b41354d75f1a0454ea47ba05fca2c43182847cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A9=E4=B8=8A=E8=82=9A=E5=AD=90=E7=96=BC?= Date: Sat, 29 Aug 2026 14:54:02 +0800 Subject: [PATCH] fix(scripts): invoke repo shell scripts through an explicit interpreter (#5031) Recipes and scripts ran sibling shell scripts bare (./scripts/x.sh), so any checkout that lost the executable bit -- zip/tarball download, core.fileMode=false, non-POSIX filesystem -- failed with: make: ./scripts/docker.sh: Permission denied make: *** [Makefile:181: docker-start] Error 127 The tracked modes are already 100755, so chmod cannot fix it. Name the interpreter instead: the POSIX branch of RUN_SHELL_SCRIPT (renamed from RUN_WITH_GIT_BASH) now expands to $(BASH) rather than nothing, and the five script-to-script call sites are prefixed with bash. Fixes #2903 Co-authored-by: zaoshangduziteng <309590849+zaoshangduziteng@users.noreply.github.com> --- Makefile | 41 ++++--- README.md | 5 + .../test_makefile_shell_script_invocation.py | 110 ++++++++++++++++++ scripts/AGENTS.md | 9 ++ scripts/docker.sh | 2 +- scripts/serve.sh | 6 +- scripts/start-daemon.sh | 2 +- 7 files changed, 151 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_makefile_shell_script_invocation.py diff --git a/Makefile b/Makefile index 687baf467..1f1bd5cbe 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,13 @@ ifeq ($(OS),Windows_NT) SHELL := cmd.exe PYTHON ?= python # Run repo shell scripts through Git Bash when Make is launched from cmd.exe / PowerShell. - RUN_WITH_GIT_BASH = call scripts\run-with-git-bash.cmd + RUN_SHELL_SCRIPT = call scripts\run-with-git-bash.cmd else PYTHON ?= python3 - RUN_WITH_GIT_BASH = + # Invoke repo shell scripts through an explicit interpreter, so recipes keep + # working in checkouts that lost the executable bit (zip download, + # core.fileMode=false, non-POSIX filesystem). + RUN_SHELL_SCRIPT = $(BASH) endif FRONTEND_PNPM = $(PYTHON) ../scripts/pnpm.py @@ -77,7 +80,7 @@ config: @$(PYTHON) ./scripts/configure.py config-upgrade: - @$(RUN_WITH_GIT_BASH) ./scripts/config-upgrade.sh + @$(RUN_SHELL_SCRIPT) ./scripts/config-upgrade.sh # Check required tools check: @@ -130,37 +133,37 @@ extension-remove: # Pre-pull sandbox Docker image (optional but recommended) setup-sandbox: - @$(RUN_WITH_GIT_BASH) ./scripts/setup-sandbox.sh + @$(RUN_SHELL_SCRIPT) ./scripts/setup-sandbox.sh # Start all services in development mode (with hot-reloading) dev: @$(PYTHON) ./scripts/check.py - @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev + @$(RUN_SHELL_SCRIPT) ./scripts/serve.sh --dev # Start all services in production mode (with optimizations). # SKIP_FRONTEND_BUILD=1 reuses the existing frontend build instead of running # `next build`; see scripts/serve.sh --skip-frontend-build. start: @$(PYTHON) ./scripts/check.py - @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod $(if $(filter 1,$(SKIP_FRONTEND_BUILD)),--skip-frontend-build) + @$(RUN_SHELL_SCRIPT) ./scripts/serve.sh --prod $(if $(filter 1,$(SKIP_FRONTEND_BUILD)),--skip-frontend-build) # Start all services in daemon mode (background) dev-daemon: @$(PYTHON) ./scripts/check.py - @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --daemon + @$(RUN_SHELL_SCRIPT) ./scripts/serve.sh --dev --daemon # Start prod services in daemon mode (background) start-daemon: @$(PYTHON) ./scripts/check.py - @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --daemon $(if $(filter 1,$(SKIP_FRONTEND_BUILD)),--skip-frontend-build) + @$(RUN_SHELL_SCRIPT) ./scripts/serve.sh --prod --daemon $(if $(filter 1,$(SKIP_FRONTEND_BUILD)),--skip-frontend-build) # Start nginx alone in the foreground with the local dev config nginx: - @$(RUN_WITH_GIT_BASH) ./scripts/nginx.sh + @$(RUN_SHELL_SCRIPT) ./scripts/nginx.sh # Stop all services stop: - @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --stop + @$(RUN_SHELL_SCRIPT) ./scripts/serve.sh --stop # Clean up clean: stop @@ -175,27 +178,27 @@ clean: stop # Initialize Docker containers and install dependencies docker-init: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh init + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh init # Start Docker development environment docker-start: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh start + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh start # Stop Docker development environment docker-stop: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh stop + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh stop # View Docker development logs docker-logs: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh logs # View Docker development logs docker-logs-frontend: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --frontend + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh logs --frontend docker-logs-gateway: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --gateway + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh logs --gateway docker-logs-redis: - @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --redis + @$(RUN_SHELL_SCRIPT) ./scripts/docker.sh logs --redis # ========================================== # Production Docker Commands @@ -203,8 +206,8 @@ docker-logs-redis: # Build and start production services up: - @$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh + @$(RUN_SHELL_SCRIPT) ./scripts/deploy.sh # Stop and remove production containers down: - @$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh down + @$(RUN_SHELL_SCRIPT) ./scripts/deploy.sh down diff --git a/README.md b/README.md index 8760ea3bb..c79c71723 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,11 @@ If you prefer running services locally: Prerequisite: complete the "Configuration" steps above first (`make setup`). `make dev` requires a valid `config.yaml` in the project root. Set `DEER_FLOW_PROJECT_ROOT` to define that root explicitly, or `DEER_FLOW_CONFIG_PATH` to point at a specific config file. Runtime state defaults to `.deer-flow` under the project root and can be moved with `DEER_FLOW_HOME`; skills default to `skills/` under the project root and can be moved with `DEER_FLOW_SKILLS_PATH`. Run `make doctor` to verify your setup before starting. On Windows, run the local development flow from Git Bash. Native `cmd.exe` and PowerShell shells are not supported for the bash-based service scripts, and WSL is not guaranteed because some scripts rely on Git for Windows utilities such as `cygpath`. +The documented root `make` commands invoke repository `.sh` files through Bash +explicitly. They therefore continue to work from source archives or filesystems +that do not preserve POSIX executable bits. When calling a script directly from +such a checkout, use `bash ./scripts/.sh ...`. + 1. **Check prerequisites**: ```bash make check # Verifies Node.js 22+, pnpm, uv, nginx diff --git a/backend/tests/test_makefile_shell_script_invocation.py b/backend/tests/test_makefile_shell_script_invocation.py new file mode 100644 index 000000000..5beda077e --- /dev/null +++ b/backend/tests/test_makefile_shell_script_invocation.py @@ -0,0 +1,110 @@ +"""Regression test keeping repo shell scripts callable without the executable bit. + +The root ``Makefile`` drives every shell script under ``scripts/`` through the +``RUN_SHELL_SCRIPT`` variable. On Windows that variable is the Git Bash shim; on +POSIX it used to expand to nothing, so recipes invoked the script directly:: + + make: ./scripts/docker.sh: Permission denied + make: *** [Makefile:181: docker-start] Error 127 + +Git tracks the executable bit, so a normal ``git clone`` is fine. It is lost in +the working tree whenever the checkout does not carry POSIX modes: source zip or +tarball downloads from the Releases/Code page, ``core.fileMode=false`` clones, +and non-POSIX filesystems (some Windows/WSL and network mounts). Nothing in the +repository can restore the bit for those users. + +Naming the interpreter removes the dependency entirely -- ``bash ./x.sh`` ignores +the mode. Every script under ``scripts/`` uses ``#!/usr/bin/env bash``, so a +single ``$(BASH)`` default is correct for all of them, and the Windows branch is +untouched. + +This test pins both halves of the invariant: no recipe may invoke a shell script +bare, and the POSIX definition of ``RUN_SHELL_SCRIPT`` must name an interpreter. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +MAKEFILE = REPO_ROOT / "Makefile" + +# A recipe line is a tab-indented command, optionally silenced with "@". +_RECIPE_SHELL_SCRIPT = re.compile(r"^\t@?(?P.*\./scripts/[\w.-]+\.sh.*)$") + + +def _recipe_lines_invoking_shell_scripts() -> list[tuple[int, str]]: + lines = MAKEFILE.read_text(encoding="utf-8").splitlines() + found = [] + for number, line in enumerate(lines, start=1): + match = _RECIPE_SHELL_SCRIPT.match(line) + if match: + found.append((number, match.group("command"))) + return found + + +def test_makefile_never_invokes_shell_scripts_bare() -> None: + """Every ``scripts/*.sh`` recipe goes through an interpreter variable.""" + invocations = _recipe_lines_invoking_shell_scripts() + assert invocations, "expected the Makefile to invoke scripts/*.sh somewhere" + + bare = [f"Makefile:{number}: {command}" for number, command in invocations if not command.startswith("$(RUN_SHELL_SCRIPT)")] + assert not bare, "these recipes invoke a shell script without an interpreter, so they fail with 'Permission denied' in checkouts that lost the executable bit; prefix them with $(RUN_SHELL_SCRIPT):\n " + "\n ".join(bare) + + +def test_posix_run_shell_script_names_an_interpreter() -> None: + """The non-Windows branch must expand to an interpreter, not to nothing.""" + text = MAKEFILE.read_text(encoding="utf-8") + + assert re.search(r"^BASH \?= bash$", text, re.MULTILINE), "expected 'BASH ?= bash' so operators can override the interpreter" + + assignments = re.findall(r"^\s*RUN_SHELL_SCRIPT\s*=\s*(.*)$", text, re.MULTILINE) + assert len(assignments) == 2, f"expected one RUN_SHELL_SCRIPT definition per platform branch, got {assignments}" + windows, posix = assignments + assert "run-with-git-bash.cmd" in windows, windows + assert posix.strip() == "$(BASH)", f"the POSIX branch must name an interpreter; an empty value makes recipes depend on the executable bit, got {posix!r}" + + +SCRIPTS_DIR = REPO_ROOT / "scripts" + +# Any reference to a shell script, however the path is spelled: "./scripts/x.sh", +# "$REPO_ROOT/scripts/x.sh", "$SCRIPT_DIR/x.sh". +_SCRIPT_REFERENCE = re.compile(r"\S*[\w.-]+\.sh\b") + +# A script name inside a usage string or hint is prose, not an invocation. +_MESSAGE_COMMAND = re.compile(r"\b(echo|printf|cat)\b") + + +def _sibling_script_invocations() -> list[tuple[str, int, str]]: + """Every line in scripts/*.sh that actually runs another shell script.""" + found = [] + for script in sorted(SCRIPTS_DIR.glob("*.sh")): + for number, raw in enumerate(script.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + for match in _SCRIPT_REFERENCE.finditer(line): + reference = match.group(0) + before = line[: match.start()] + if "://" in reference: # a download URL, not a local script + continue + if reference.endswith(script.name): # usage string / log prefix + continue + if _MESSAGE_COMMAND.search(before): # a hint printed to the user + continue + if not re.search(r"\bbash\s+$", before): + found.append((script.name, number, line)) + break + return found + + +def test_scripts_never_invoke_sibling_scripts_bare() -> None: + """A script calling another script must name the interpreter too. + + ``make docker-stop`` reaches ``scripts/cleanup-containers.sh`` through + ``scripts/docker.sh``, so fixing only the Makefile would leave the same + ``Permission denied`` failure one level deeper. + """ + bare = [f"scripts/{name}:{number}: {line}" for name, number, line in _sibling_script_invocations()] + assert not bare, "these lines run another shell script without an interpreter, so they fail with 'Permission denied' in checkouts that lost the executable bit; prefix them with 'bash':\n " + "\n ".join(bare) diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index a0dce62b4..e8a745bc3 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -6,6 +6,15 @@ synchronized environment with `uv run --no-sync`. Production Compose probes Gateway `/health`, and `deploy.sh` waits for all services before reporting success; failures print Compose status and recent Gateway logs. +## Shell Script Invocation Contract + +Root Makefile recipes must invoke repository `.sh` files through +`RUN_SHELL_SCRIPT`. On POSIX this expands to `$(BASH)`; on Windows it uses the +Git Bash wrapper. Shell scripts that invoke sibling repository scripts must +likewise prefix the target with `bash`. This keeps documented `make` commands +working when a source archive, `core.fileMode=false`, or a non-POSIX filesystem +does not preserve executable bits. + ## Backend Static Analysis Commands The root `detect-thread-boundaries` target statically inventories execution diff --git a/scripts/docker.sh b/scripts/docker.sh index 8b84a3707..405bceb7e 100755 --- a/scripts/docker.sh +++ b/scripts/docker.sh @@ -456,7 +456,7 @@ stop() { echo "Stopping Docker development services..." cd "$DOCKER_DIR" && $COMPOSE_CMD down echo "Cleaning up sandbox containers..." - "$SCRIPT_DIR/cleanup-containers.sh" deer-flow-sandbox 2>/dev/null || true + bash "$SCRIPT_DIR/cleanup-containers.sh" deer-flow-sandbox 2>/dev/null || true echo -e "${GREEN}✓ Docker services stopped${NC}" } diff --git a/scripts/serve.sh b/scripts/serve.sh index aa72d14b5..4d7f86422 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -268,7 +268,7 @@ stop_all() { _kill_repo_port 8001 _kill_repo_port 3000 _kill_repo_port 2026 - ./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true + bash ./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true echo "✓ All services stopped" } @@ -377,7 +377,7 @@ if ! { \ exit 1 fi -"$REPO_ROOT/scripts/config-upgrade.sh" +bash "$REPO_ROOT/scripts/config-upgrade.sh" # ── Install dependencies ──────────────────────────────────────────────────── @@ -471,7 +471,7 @@ run_service() { sh -c "$cmd" & fi - ./scripts/wait-for-port.sh "$port" "$timeout" "$name" || { + bash ./scripts/wait-for-port.sh "$port" "$timeout" "$name" || { local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log" echo "✗ $name failed to start." [ -f "$logfile" ] && tail -20 "$logfile" diff --git a/scripts/start-daemon.sh b/scripts/start-daemon.sh index 8822b73a0..ac1c20f3f 100755 --- a/scripts/start-daemon.sh +++ b/scripts/start-daemon.sh @@ -6,4 +6,4 @@ # Kept for backward compatibility. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -exec "$REPO_ROOT/scripts/serve.sh" --dev --daemon "$@" +exec bash "$REPO_ROOT/scripts/serve.sh" --dev --daemon "$@"