mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* fix(docker): let the Gateway write extensions_config.json in production AGENTS.md states extensions_config.json may be edited at runtime through the Gateway API, and the Gateway implements that for the MCP enable switch, PUT/PATCH /api/mcp/config and the skill update route. Two properties of the production compose stack made every one of those writes fail: - the file was mounted read-only, and - Docker mounts it as its own mount point, so the temp-file-plus-rename in atomic_write_extensions_config hit EBUSY. Linux refuses rename() over a mount point whether or not the mount is writable, so making the mount read-write alone is not enough. Mount it read-write and fall back to an in-place overwrite on EBUSY only. The fallback is deliberately non-atomic and says so in a warning; it is reached only where the atomic route cannot work, and any other errno still propagates. config.yaml stays read-only: no API writes it. docker-compose-dev.yaml mounts the whole project directory, so the destination is an ordinary file there and this never surfaced in development. * test(docker): parse mount options instead of matching a :ro suffix Docker's short-syntax options segment is comma-separated, so a read-only mount can legally be spelled ":ro,z" or ":z,ro" — common with SELinux relabelling. Matching the raw string for a ":ro" suffix reads those as writable, which silently defeats the guard: the writability assertion would pass on a read-only mount, and the config.yaml assertion would fail on a correctly read-only one. Parse the options segment and test membership instead, and cover the parser with the spellings that broke the suffix check. * fix(config): harden mutable extensions config
81 lines
3.1 KiB
Python
81 lines
3.1 KiB
Python
"""Regression test for the writability of the mounted extensions config.
|
|
|
|
``AGENTS.md`` states that ``config.yaml`` / ``extensions_config.json`` "may be
|
|
edited at runtime via the Gateway API", and the Gateway implements exactly that
|
|
for the latter: ``PUT``/``PATCH /api/mcp/config``, the MCP enable/disable switch
|
|
in the settings UI, and the skill update route all funnel into
|
|
``atomic_write_extensions_config``.
|
|
|
|
The production compose file mounted that path read-only, so every one of those
|
|
writes failed against the shipped artifact. Two independent kernel behaviours
|
|
were involved and both are covered here plus in
|
|
``test_extensions_config_atomic_write.py``:
|
|
|
|
1. A read-only bind mount rejects the write outright.
|
|
2. Even read-write, the destination is its own mount point, and Linux refuses
|
|
``rename()`` over a mount point with ``EBUSY``. That half is handled by the
|
|
in-place fallback in ``atomic_write_extensions_config``.
|
|
|
|
``config.yaml`` deliberately stays read-only: no API writes it, and the
|
|
top-level ``plugins:`` list it carries causes code to be imported, so it is
|
|
kept out of the API-writable surface on purpose.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
PROD_COMPOSE = REPO_ROOT / "docker" / "docker-compose.yaml"
|
|
|
|
EXTENSIONS_CONFIG_TARGET = "/app/backend/extensions_config.json"
|
|
APP_CONFIG_TARGET = "/app/backend/config.yaml"
|
|
|
|
|
|
def _gateway_volume_for(target: str) -> str:
|
|
compose = yaml.safe_load(PROD_COMPOSE.read_text(encoding="utf-8"))
|
|
volumes = compose["services"]["gateway"]["volumes"]
|
|
matches = [str(entry) for entry in volumes if str(entry).split(":")[1:2] == [target]]
|
|
assert len(matches) == 1, f"expected exactly one gateway mount for {target}, got {matches}"
|
|
return matches[0]
|
|
|
|
|
|
def _mount_options(volume: str) -> set[str]:
|
|
"""Return the option flags of a short-syntax ``source:target[:options]`` mount.
|
|
|
|
Options are comma-separated, so ``ro`` can legally appear as ``ro,z`` or
|
|
``z,ro``. Testing the raw string for a ``:ro`` suffix would read those as
|
|
writable and let a read-only regression through.
|
|
"""
|
|
parts = volume.split(":")
|
|
if len(parts) < 3:
|
|
return set()
|
|
return {option.strip() for option in parts[2].split(",") if option.strip()}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("volume", "expected"),
|
|
[
|
|
("./src:/dst", set()),
|
|
("./src:/dst:ro", {"ro"}),
|
|
("./src:/dst:ro,z", {"ro", "z"}),
|
|
("./src:/dst:z,ro", {"z", "ro"}),
|
|
("./src:/dst:rw", {"rw"}),
|
|
],
|
|
)
|
|
def test_mount_options_parses_comma_separated_flags(volume: str, expected: set[str]) -> None:
|
|
assert _mount_options(volume) == expected
|
|
|
|
|
|
def test_extensions_config_is_mounted_writable() -> None:
|
|
mount = _gateway_volume_for(EXTENSIONS_CONFIG_TARGET)
|
|
assert "ro" not in _mount_options(mount), f"the Gateway writes {EXTENSIONS_CONFIG_TARGET} at runtime, so it must not be mounted read-only: {mount}"
|
|
|
|
|
|
def test_app_config_stays_read_only() -> None:
|
|
mount = _gateway_volume_for(APP_CONFIG_TARGET)
|
|
assert "ro" in _mount_options(mount), f"no API writes {APP_CONFIG_TARGET}; it must stay read-only: {mount}"
|