diff --git a/README.md b/README.md index ca1aade939..e3201b5fbc 100644 --- a/README.md +++ b/README.md @@ -1920,6 +1920,10 @@ overwriting them with an unevaluated fallback — so the Settings UI shows whether `lark-cli` will actually be present in the sandbox at chat time, rather than a green status hiding a later `command not found`. +The optional [Lark CLI broker image](docker/lark-cli-broker/README.md) bundles +its required Python helper modules, so the sidecar runs without a separate +DeerFlow harness installation. + In Lark broker mode, AIO's fresh Bash runs close inherited pipe stdin. Persistent terminal commands keep their input; the shim ignores terminal stdin. Explicit pipelines, heredocs, and file redirections still supply input normally, and the diff --git a/backend/packages/harness/deerflow/integrations/lark_broker.py b/backend/packages/harness/deerflow/integrations/lark_broker.py index d4eddfcbb8..18d360a9c8 100644 --- a/backend/packages/harness/deerflow/integrations/lark_broker.py +++ b/backend/packages/harness/deerflow/integrations/lark_broker.py @@ -11,8 +11,8 @@ loopback. The sandbox gets a tiny ``lark-cli`` shim on ``PATH`` that forwards argv/stdin to the broker, so the raw credential files never exist in the sandbox filesystem. -Everything here is Python-3-stdlib only so the same module can run inside the -minimal broker sidecar image without extra dependencies. +The broker and its bundled DeerFlow utility modules use only Python's standard +library, so the minimal sidecar needs no third-party Python packages. """ from __future__ import annotations @@ -32,6 +32,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any +# Keep the standalone image's Python COPY list in sync with this import and its +# transitive dependencies: docker/lark-cli-broker/Dockerfile. from deerflow.utils.atomic_write import cleanup_failed_atomic_write logger = logging.getLogger(__name__) diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index 2213a68b0d..596fbafb20 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -1,5 +1,10 @@ # Backend Tests +`test_lark_broker_image.py` stages Python files from the broker Dockerfile's +final-stage COPY instructions. Run shim installation and the health endpoint +under `-I -S`, adding only the staged script directory to the import path; +repository paths or installed Harness packages would hide missing image files. + When adding a scoped `AGENTS.md`, update `EXPECTED_GUIDANCE_PATHS` in `test_agent_guidance_check.py` in the same change. The explicit inventory pins guidance ownership and extends hard-budget checks to each approved guide. diff --git a/backend/tests/test_lark_broker_image.py b/backend/tests/test_lark_broker_image.py new file mode 100644 index 0000000000..b5a094eb19 --- /dev/null +++ b/backend/tests/test_lark_broker_image.py @@ -0,0 +1,95 @@ +"""Exercise the broker image's Python file layout without an installed harness.""" + +from __future__ import annotations + +import json +import shlex +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def image_broker(tmp_path: Path) -> Path: + dockerfile = REPO_ROOT / "docker/lark-cli-broker/Dockerfile" + runtime_stage = dockerfile.read_text(encoding="utf-8").replace("\\\n", " ").rsplit("\nFROM ", 1)[1] + image_root = tmp_path / "image" + for instruction in runtime_stage.splitlines(): + words = shlex.split(instruction, comments=True) + if not words or words[0] != "COPY" or words[1].startswith("--from="): + continue + for source in words[1:-1]: + if not source.endswith(".py"): + continue + destination = image_root / words[-1].lstrip("/") + if words[-1].endswith("/"): + destination /= Path(source).name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPO_ROOT / source, destination) + return image_root / "opt/broker/lark_broker.py" + + +def run_isolated(broker: Path, code: str, *args: str) -> subprocess.CompletedProcess[str]: + bootstrap = "import runpy, sys\nfrom pathlib import Path\nbroker = Path(sys.argv.pop(1))\nsys.path.insert(0, str(broker.parent))\n" + # -I ignores inherited PYTHONPATH and -S skips installed editable packages. + # Only the image's script directory is added back, matching its entrypoint. + return subprocess.run( + [sys.executable, "-I", "-S", "-c", bootstrap + textwrap.dedent(code), str(broker), *args], + cwd=broker.parent, + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + check=False, + ) + + +def test_image_installs_shim_without_harness(image_broker: Path, tmp_path: Path) -> None: + runtime = tmp_path / "runtime" + completed = run_isolated(image_broker, 'runpy.run_path(str(broker), run_name="__main__")', "install-shim", str(runtime)) + + assert completed.returncode == 0, completed.stderr + launcher = runtime / "bin/lark-cli" + shim = runtime / "bin/lark-cli-shim.py" + assert launcher.read_text(encoding="utf-8").startswith("#!/bin/sh\n") + assert str(shim) in launcher.read_text(encoding="utf-8") + assert shim.read_text(encoding="utf-8").startswith("#!/usr/bin/env python3\n") + assert json.loads((runtime / ".deerflow-lark-cli-runtime.json").read_text(encoding="utf-8"))["kind"] == "shim" + + +def test_image_serves_health_without_harness(image_broker: Path) -> None: + completed = run_isolated( + image_broker, + """ + import json + import threading + from http.client import HTTPConnection + + module = runpy.run_path(str(broker)) + config = module["BrokerConfig"](lark_cli_path=sys.executable, config_dir="config", data_dir="data", port=0) + with module["serve"](config) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = HTTPConnection(*server.server_address[:2], timeout=5) + try: + connection.request("GET", "/v1/health") + response = connection.getresponse() + assert response.status == 200 + assert json.loads(response.read()) == {"ok": True} + finally: + connection.close() + finally: + server.shutdown() + thread.join(timeout=5) + assert not thread.is_alive() + """, + ) + + assert completed.returncode == 0, completed.stderr diff --git a/docker/lark-cli-broker/Dockerfile b/docker/lark-cli-broker/Dockerfile index 49ffe37505..70658fa212 100644 --- a/docker/lark-cli-broker/Dockerfile +++ b/docker/lark-cli-broker/Dockerfile @@ -54,15 +54,18 @@ COPY docker/lark-cli-init/build-runtime.sh /usr/local/bin/build-runtime.sh RUN chmod +x /usr/local/bin/build-runtime.sh \ && LARK_CLI_VERSION="${LARK_CLI_VERSION}" /usr/local/bin/build-runtime.sh /opt/lark-cli -# ── Final image: python3 + the staged real binary + the broker module ──────── +# ── Final image: python3 + the staged real binary + the broker runtime ─────── FROM python:3.12-slim ARG LARK_CLI_VERSION=v1.0.65 ENV LARK_CLI_VERSION=${LARK_CLI_VERSION} COPY --from=builder /opt/lark-cli /opt/lark-cli -# Single stdlib-only module drives both install-shim and serve. +# Bundle the broker's stdlib-only dependency closure without installing the harness. COPY backend/packages/harness/deerflow/integrations/lark_broker.py /opt/broker/lark_broker.py +COPY backend/packages/harness/deerflow/utils/atomic_write.py \ + backend/packages/harness/deerflow/utils/file_revision_metadata.py \ + /opt/broker/deerflow/utils/ COPY docker/lark-cli-broker/entrypoint.sh /usr/local/bin/lark-cli-broker RUN chmod +x /usr/local/bin/lark-cli-broker diff --git a/docker/lark-cli-broker/README.md b/docker/lark-cli-broker/README.md index c409494b66..640a739e51 100644 --- a/docker/lark-cli-broker/README.md +++ b/docker/lark-cli-broker/README.md @@ -39,6 +39,9 @@ image ships Python 3, so the default path needs no configuration. ## Build +The image bundles the broker script and its shared atomic-write helper modules. +It requires no separate DeerFlow harness installation for either mode. + Build context is the **repo root** (the broker module lives under `backend/`): ```bash diff --git a/docker/lark-cli-broker/entrypoint.sh b/docker/lark-cli-broker/entrypoint.sh index 4646fe4c7d..179db60c41 100644 --- a/docker/lark-cli-broker/entrypoint.sh +++ b/docker/lark-cli-broker/entrypoint.sh @@ -1,14 +1,14 @@ #!/bin/sh # DeerFlow lark-cli broker entrypoint (Pattern B, issue #4338). # -# Dispatches to one of two modes on the single stdlib-only module: +# Dispatches to one of two modes on the broker script: # # install-shim [dest] write the shim + runtime marker into the shared # emptyDir (init-container mode), then exit 0. # serve run the broker HTTP server on loopback (sidecar mode). # -# The module is copied to /opt/broker/lark_broker.py and run as a script; it has -# no third-party dependencies, so no harness install is needed in this image. +# The script and its shared helpers live under /opt/broker and use only the +# Python standard library; no harness installation is needed in this image. set -eu MODULE="/opt/broker/lark_broker.py"