diff --git a/README.md b/README.md
index 2f20347a0e..87f95e517c 100644
--- a/README.md
+++ b/README.md
@@ -770,6 +770,10 @@ DeerFlow runs the agent runtime inside the Gateway API. Development mode enables
| **Stop** | `./scripts/serve.sh --stop`
`make stop` | `./scripts/docker.sh stop`
`make docker-stop` | `./scripts/deploy.sh down`
`make down` |
| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — |
+Local and Docker development stop commands also stop running sandbox containers
+whose names start with `deer-flow-sandbox-`. Containers with that text elsewhere
+in their names are left alone.
+
`make start` and `make start-daemon` rebuild the frontend with `next build` on
every run. To reuse the last build instead, pass `SKIP_FRONTEND_BUILD=1` (or add
`--skip-frontend-build` when calling `./scripts/serve.sh --prod` directly). This
diff --git a/backend/tests/test_cleanup_containers_script.py b/backend/tests/test_cleanup_containers_script.py
new file mode 100644
index 0000000000..923cb75a89
--- /dev/null
+++ b/backend/tests/test_cleanup_containers_script.py
@@ -0,0 +1,62 @@
+"""Offline checks for the names selected by the sandbox cleanup helper."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "cleanup-containers.sh"
+
+
+def _stub(path: Path, name: str, source: str) -> None:
+ executable = path / name
+ executable.write_text("#!/bin/sh\n" + source, encoding="utf-8", newline="\n")
+ executable.chmod(0o755)
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX executable stubs")
+def test_docker_cleanup_stops_only_sandbox_prefix(tmp_path: Path) -> None:
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ stop_log = tmp_path / "stopped"
+ _stub(
+ bin_dir,
+ "docker",
+ 'if [ "$1" = ps ]; then\n'
+ ' printf "other-id not-deer-flow-sandbox Up 3 days\\nvalid-id deer-flow-sandbox-abc Up 1 hour\\nbackup-id deer-flow-sandboxbackup Up 2 days\\n"\n'
+ 'elif [ "$1" = stop ]; then\n'
+ ' shift; printf "%s\\n" "$@" >> "$STOP_LOG"\n'
+ "fi\n",
+ )
+ env = {**os.environ, "PATH": f"{bin_dir}:/usr/bin:/bin", "STOP_LOG": str(stop_log)}
+ result = subprocess.run(["bash", str(SCRIPT), "deer-flow-sandbox"], env=env, capture_output=True, text=True, timeout=5)
+
+ assert result.returncode == 0, result.stderr
+ assert stop_log.read_text(encoding="utf-8").splitlines() == ["valid-id"]
+ assert "not-deer-flow-sandbox" not in result.stdout
+ assert "deer-flow-sandbox-abc Up 1 hour" in result.stdout
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX executable stubs")
+def test_apple_cleanup_ignores_unrelated_names_and_bad_entries(tmp_path: Path) -> None:
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ stop_log = tmp_path / "stopped"
+ entries = [
+ {"configuration": None},
+ {"configuration": {"id": "not-deer-flow-sandbox"}},
+ {"configuration": {"id": "deer-flow-sandbox-abc"}},
+ ]
+ payload = tmp_path / "containers.json"
+ payload.write_text(json.dumps(entries), encoding="utf-8")
+ _stub(bin_dir, "container", 'if [ "$1" = list ]; then cat "$CONTAINERS_JSON"; elif [ "$1" = stop ]; then printf "%s\\n" "$2" >> "$STOP_LOG"; fi\n')
+ env = {**os.environ, "PATH": f"{bin_dir}:/usr/bin:/bin", "STOP_LOG": str(stop_log), "CONTAINERS_JSON": str(payload)}
+ result = subprocess.run(["bash", str(SCRIPT), "deer-flow-sandbox"], env=env, capture_output=True, text=True, timeout=5)
+
+ assert result.returncode == 0, result.stderr
+ assert stop_log.read_text(encoding="utf-8").splitlines() == ["deer-flow-sandbox-abc"]
+ assert "not-deer-flow-sandbox" not in result.stdout
diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md
index 7c25b97913..89aa9a29ca 100644
--- a/scripts/AGENTS.md
+++ b/scripts/AGENTS.md
@@ -214,6 +214,11 @@ runs `check-data-not-in-use.sh` before `make stop` (which would stop a live
stack's sandboxes) and refuses while that container runs; an absent or
unreachable Docker passes. `make stop` must still run before the delete: it
stops `deer-flow-sandbox*` containers, whose thread mounts live in that tree.
+`cleanup-containers.sh` must verify an anchored `-` name after Docker's
+substring `name=` filter, and use the same rule for Apple Container IDs. Parse
+Apple Container JSON without interpolating the prefix into Python source; bad
+entries must not hide later valid containers. Offline stub coverage lives in
+`backend/tests/test_cleanup_containers_script.py`.
`RUNTIME_DATA_CONTENTS` feeds both the help line and the deletion notice.
Host-side pnpm calls must go through `scripts/pnpm.py`. With native Windows
diff --git a/scripts/cleanup-containers.sh b/scripts/cleanup-containers.sh
index 7e44b602a6..caa613d4ad 100755
--- a/scripts/cleanup-containers.sh
+++ b/scripts/cleanup-containers.sh
@@ -22,14 +22,23 @@ echo "Cleaning up sandbox containers with prefix: ${PREFIX}"
cleanup_docker() {
if command -v docker &> /dev/null; then
echo -n "Checking Docker containers... "
- DOCKER_CONTAINERS=$(docker ps -q --filter "name=${PREFIX}" 2>/dev/null || echo "")
+ # Docker's name filter matches substrings. Check the returned names as
+ # well so a container like not-deer-flow-sandbox is never stopped.
+ DOCKER_LIST=$(docker ps --filter "name=${PREFIX}-" --format '{{.ID}} {{.Names}} {{.Status}}' 2>/dev/null || true)
+ docker_ids=()
+ docker_rows=()
+ while read -r cid name status; do
+ case "$name" in
+ "$PREFIX"-*) docker_ids+=("$cid"); docker_rows+=("$cid $name $status") ;;
+ esac
+ done <<< "$DOCKER_LIST"
- if [ -n "$DOCKER_CONTAINERS" ]; then
+ if [ "${#docker_ids[@]}" -gt 0 ]; then
echo ""
echo "Found Docker containers to clean up:"
- docker ps --filter "name=${PREFIX}" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}"
+ printf ' %s\n' "${docker_rows[@]}"
echo "Stopping Docker containers..."
- echo "$DOCKER_CONTAINERS" | xargs docker stop 2>/dev/null || true
+ docker stop "${docker_ids[@]}" >/dev/null 2>&1 || true
echo -e "${GREEN}✓ Docker containers stopped${NC}"
else
echo -e "${GREEN}none found${NC}"
@@ -49,21 +58,23 @@ cleanup_apple_container() {
if [ "$CONTAINER_LIST" != "[]" ] && [ -n "$CONTAINER_LIST" ]; then
# Extract container IDs that match our prefix
- CONTAINER_IDS=$(echo "$CONTAINER_LIST" | python3 -c "
+ CONTAINER_IDS=$(echo "$CONTAINER_LIST" | CLEANUP_CONTAINER_PREFIX="$PREFIX" python3 -c '
import json
+import os
import sys
try:
containers = json.load(sys.stdin)
if isinstance(containers, list):
+ prefix = os.environ["CLEANUP_CONTAINER_PREFIX"] + "-"
for c in containers:
if isinstance(c, dict):
- # Apple Container uses 'id' field which contains the container name
- cid = c.get('configuration').get('id', '')
- if '${PREFIX}' in cid:
+ configuration = c.get("configuration")
+ cid = configuration.get("id") if isinstance(configuration, dict) else None
+ if isinstance(cid, str) and cid.startswith(prefix):
print(cid)
except:
pass
-" 2>/dev/null || echo "")
+' 2>/dev/null || echo "")
if [ -n "$CONTAINER_IDS" ]; then
echo ""