Daoyuan Li 3ed2e1f1d9
fix(config): close out #4124 review follow-ups (shared signature helper, resolve_config_path None contract) (#4275)
* fix(config): close out #4124 review follow-ups

Extracts the (mtime, size, sha256) content-signature helper that was
duplicated between config/app_config.py and mcp/cache.py into a new
config/file_signature.py, and fixes ExtensionsConfig.resolve_config_path()
to return None instead of raising FileNotFoundError when an explicit
config_path argument or DEER_FLOW_EXTENSIONS_CONFIG_PATH points at a file
that has since been deleted -- the exact resolution mode Docker dev/prod
uses per AGENTS.md, so the MCP tools-cache staleness check could raise
instead of degrading to "not stale". Both were flagged by willem-bd in
review on #4124 and explicitly deferred there ("flagging for visibility",
"leaving the extraction as the follow-up you suggested").

* fix(config): keep explicit extensions-config paths fail-loud

resolve_config_path() previously turned every missing-file case into a
clean None, including an explicit config_path argument or
DEER_FLOW_EXTENSIONS_CONFIG_PATH (the exact mode Docker dev/prod uses).
That silently downgrades a bad Docker mount, typo, or deleted
production config to "no extensions" instead of surfacing the
misconfiguration, per fancyboi999's review [P1] and willem-bd's
follow-up notes on this PR.

Restores FileNotFoundError for the two explicit modes (config_path
argument, DEER_FLOW_EXTENSIONS_CONFIG_PATH); only the fallback search
mode (no explicit path/env var, nothing found in the usual locations)
still returns None, since that is the legitimate "extensions were
never configured" case.

The one caller that needs the old fail-soft behavior -- the MCP
tools-cache staleness check, which re-resolves the path on every
get_cached_mcp_tools() call -- gets a narrow, local catch instead
(deerflow.mcp.cache._resolve_config_path) so a config file going
missing mid-run still degrades the cache to "not stale" rather than
crashing a hot per-request path. Also hoists the double os.getenv()
read in the env-var branch into a local, per willem-bd's nit.

Adds resolver-level tests for both explicit-path and env-var raises,
a dedicated search-mode None regression test, and updates the
existing MCP-cache docstrings to describe the corrected split.
2026-07-19 22:12:30 +08:00

56 lines
2.4 KiB
Python

"""Shared content-signature helper for runtime-editable config files.
Both ``config/app_config.py`` (``config.yaml``) and ``mcp/cache.py``
(``extensions_config.json``) need to detect when a runtime-editable config
file has actually changed, even under conditions a bare mtime comparison
misses: same-second edits, mtime that stays put or moves backward
(``git checkout``, ``cp -p`` / backup restore, ``tar`` / ``rsync`` that
preserve timestamps, object-store / network mounts), or a switch to a
different file whose mtime is <= the previously recorded one.
This module is the single implementation of that ``(mtime, size, sha256)``
signature so the two call sites share one behavior instead of maintaining
verbatim-duplicate copies that can silently drift apart over time.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
# (mtime, size, sha256-hexdigest) recorded for a config file, or the current
# values recomputed for comparison against a previously recorded one. A
# ``None`` digest (third element) means the stat succeeded but the content
# could not be read; the whole tuple is ``None`` when the file could not be
# stat-ed at all (e.g. it does not exist).
ConfigSignature = tuple[float | None, int | None, str | None]
def get_config_signature(config_path: Path) -> ConfigSignature | None:
"""Get cache metadata for *config_path*, including a content digest.
Returns ``None`` when the file cannot be stat-ed (e.g. it does not
exist), so callers can treat "no file" as a distinct case from "file
with unreadable content" (which still yields a partial signature below).
"""
try:
stat_result = config_path.stat()
except OSError:
return None
# Always hash the full file here rather than short-circuiting when
# mtime/size already match a previously recorded signature: swapping in
# different content of identical byte length within the same second
# leaves mtime *and* size unchanged, so only the sha256 catches that
# swap. Skipping the hash on an mtime/size match would reopen the narrow
# gap this signature was built to close.
digest = hashlib.sha256()
try:
with config_path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
except OSError:
return (stat_result.st_mtime, stat_result.st_size, None)
return (stat_result.st_mtime, stat_result.st_size, digest.hexdigest())