From d649cc73d23f401c8b6392292fb174563968e035 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Wed, 21 Oct 2026 23:43:49 +0900 Subject: [PATCH] fix(skills): create a missing extensions_config.json in the project root (#7048) * fix(skills): create a missing extensions_config.json in the project root When no extensions config resolved, the skill toggle created one at Path.cwd().parent while the MCP writers used project_root(). Unless the Gateway runs from backend/ with the project root at its parent, the toggle's file landed outside the resolver's search path: the toggle was lost on reload and the next MCP edit created a second file. Add ExtensionsConfig.default_config_path() (the project root, the first location resolve_config_path searches) and use it from the skill toggle and both MCP create paths, so a created file is always the one the next resolution reads back. * docs(changelog): reference #7048 for the extensions config create path --------- Co-authored-by: Willem Jiang --- CHANGELOG.md | 19 ++++++- CHANGELOG_zh.md | 16 +++++- backend/app/gateway/routers/mcp.py | 5 +- backend/app/gateway/routers/skills.py | 2 +- .../harness/deerflow/config/AGENTS.md | 6 ++- .../deerflow/config/extensions_config.py | 14 ++++- backend/tests/test_mcp_config_secrets.py | 54 ++++++++++++++++++- backend/tests/test_runtime_paths.py | 24 +++++++++ backend/tests/test_skills_custom_router.py | 10 ++-- 9 files changed, 137 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f975e6f52..bf4c407469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2086,6 +2086,7 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. `channel_conversations` / `channel_thread_bindings`: the instance that loses adopts the winner's thread and deletes its own empty one, and `/new` still rebinds. (issue #7103, [#7104]) + - **skills:** A skill the agent creates, edits, patches or deletes with the `skill_manage` tool now reaches every Gateway process sharing the home volume, as a change made through the skills API already did. The tool @@ -2097,12 +2098,14 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. this or a peer process serving the stale list. When the marker cannot be written, the tool reports that the change was applied but other processes were not notified, instead of reading as a failed write. ([#7099]) + - **provisioner:** When two provisioner replicas start together and `K8S_NAMESPACE` does not exist yet, the one that loses the create race no longer crashes at startup: its `409 AlreadyExists` now counts as the namespace existing, as the Pod and Service creates already did. Helm deployments, where the provisioner runs inside the namespace it manages, never took this path. ([#7099]) + - **projects:** A shelf upload or save-from-thread into a project the caller cannot write no longer leaves a directory behind or answers `500`. Both routes staged the body under `users/{user_id}/projects/{project_id}/` before @@ -2112,6 +2115,18 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. now check for an owned, active project first and answer the same `404` without writing; the shelf insert still re-checks under the project row lock. ([#7040]) + +- **skills:** Toggling a skill when no `extensions_config.json` exists yet now + creates the file in the project root (`DEER_FLOW_PROJECT_ROOT`, else the + working directory), where the MCP editor already creates it and where config + resolution looks first. The toggle used the working directory's parent + instead: with the Gateway started from anywhere but `backend/`, the new file + landed outside the search path, so the toggle was silently lost after reload + and the next MCP edit created a second file. Both routers now share + `ExtensionsConfig.default_config_path()`. Docker and `make dev` were not + affected: they set the config path, or a project root equal to that parent. + ([#7048]) + - **workflows:** A script workflow waiting on a long child Agent run no longer fails with `Worker turn limit` after about 209 seconds regardless of `timeout_seconds`. The supervisor re-sent an empty frame to the QuickJS worker @@ -2120,7 +2135,8 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. and the supervisor then waits on the pending calls locally (still checking cancellation, faults and worker exits every 20 ms) without exchanging frames, so idle waits no longer consume turns and `timeout_seconds` is the only wall-clock bound; - scripts that spin the job queue still hit the turn cap. (issue #7022, [#7024]) + scripts that spin the job queue still hit the turn cap. (issue [#7022], [#7024]) + - **browser:** `browser_screenshot` no longer replaces a file that already exists in the thread's outputs directory. A screenshot saved as `report.png` used to overwrite the user's own `report.png`, an earlier @@ -12930,6 +12946,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7035]: https://github.com/bytedance/deer-flow/pull/7035 [#7039]: https://github.com/bytedance/deer-flow/pull/7039 [#7040]: https://github.com/bytedance/deer-flow/pull/7040 +[#7048]: https://github.com/bytedance/deer-flow/pull/7048 [#7042]: https://github.com/bytedance/deer-flow/pull/7042 [#7051]: https://github.com/bytedance/deer-flow/pull/7051 [#7053]: https://github.com/bytedance/deer-flow/pull/7053 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 6647389731..1063c66cd0 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1631,29 +1631,42 @@ 两个实例为同一会话的第一条消息各自创建线程时,绑定现在以“不存在才插入”的方式在 `channel_conversations` / `channel_thread_bindings` 中认领:落败的实例改用胜出者的线程 并删除自己创建的空线程,`/new` 仍会重新绑定到新线程。 (issue #7103, [#7104]) + - **技能:** agent 通过 `skill_manage` 工具创建、编辑、修补或删除的技能,现在会像通过 技能 API 所做的修改一样,传达到共享同一 home 卷的每个 Gateway 进程。此前该工具只刷新 本进程的技能提示缓存,对端副本会继续为该用户渲染旧的技能列表;现在它在本地刷新之后、 于事件循环之外发布同一个按用户限定的共享缓存重置标记。写入、历史记录、刷新与标记作为 一个整体完成,因此写入途中被取消的运行不再让本进程或对端继续使用过期列表。标记无法写入 时,工具会报告修改已生效但未能通知其他进程,而不会让人误以为写入失败。 ([#7099]) + - **provisioner:** 两个 provisioner 副本在 `K8S_NAMESPACE` 尚不存在时同时启动,输掉 创建竞争的一方不再在启动时崩溃:它收到的 `409 AlreadyExists` 现在视为命名空间已存在, 与 Pod 和 Service 的创建一致。Helm 部署中 provisioner 运行在其管理的命名空间内,从不 经过这条路径。 ([#7099]) + - **项目:** 向调用者无权写入的项目上传书架文档或从对话保存文件时,不再遗留目录, 也不再返回 `500`。这两个路由此前先把请求体暂存到 `users/{user_id}/projects/{project_id}/` 下才检查项目,因此不存在、属于他人或已归档 的 id 会留下一个回收站对账永远不会清理的空 `{project_id}/documents/` 目录,而超过单个 路径分量长度的 id 会因 `ENAMETOOLONG` 失败。现在会先确认项目归调用者所有且处于活跃 状态,不写任何内容即返回同样的 `404`;书架插入仍会在项目行锁下再次检查。 ([#7040]) + +- **skills:** 在尚无 `extensions_config.json` 时切换技能开关,现在会在项目根目录 + (`DEER_FLOW_PROJECT_ROOT`,未设置时为工作目录)创建该文件,这与 MCP 编辑器已有的 + 创建位置一致,也是配置解析最先查找的位置。此前技能开关使用工作目录的父目录:当 + Gateway 不是从 `backend/` 启动时,新文件会落在查找路径之外,重新加载后开关被静默 + 丢弃,之后的 MCP 编辑还会再创建第二个文件。现在两个路由共用 + `ExtensionsConfig.default_config_path()`。Docker 与 `make dev` 不受影响:它们设置了 + 配置路径,或者项目根目录恰好就是那个父目录。 ([#7048]) + - **工作流:** 脚本工作流等待耗时较长的子 Agent 运行时,不再无视 `timeout_seconds`, 在约 209 秒后因 `Worker turn limit` 失败。此前只要步骤尚未完成,监督器就会在每次 20 ms 轮询时向 QuickJS worker 重发一个空帧,每次往返都会计入 10,000 次交互上限。 现在 worker 会报告其作业队列何时已排空,随后监督器在本地等待尚未完成的调用,不再 交换帧(仍每隔 20 ms 检查取消、故障和 worker 退出),因此空闲等待不再消耗交互次数, `timeout_seconds` 成为唯一的实际耗时上限;持续驱动作业队列的脚本仍受交互次数上限 - 约束。(issue #7022, [#7024]) + 约束。(issue [#7022], [#7024]) + - **浏览器:** `browser_screenshot` 不再替换线程 outputs 目录中已存在的文件。此前以 `report.png` 保存的截图会覆盖用户自己的 `report.png`、更早的截图,或同一秒内生成的 另一张默认命名截图;现在会像 `web_capture` 一样改用下一个空闲名称(`report-1.png`、 @@ -10312,6 +10325,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7035]: https://github.com/bytedance/deer-flow/pull/7035 [#7039]: https://github.com/bytedance/deer-flow/pull/7039 [#7040]: https://github.com/bytedance/deer-flow/pull/7040 +[#7048]: https://github.com/bytedance/deer-flow/pull/7048 [#7042]: https://github.com/bytedance/deer-flow/pull/7042 [#7051]: https://github.com/bytedance/deer-flow/pull/7051 [#7053]: https://github.com/bytedance/deer-flow/pull/7053 diff --git a/backend/app/gateway/routers/mcp.py b/backend/app/gateway/routers/mcp.py index 1470118133..0751abfc07 100644 --- a/backend/app/gateway/routers/mcp.py +++ b/backend/app/gateway/routers/mcp.py @@ -29,7 +29,6 @@ from deerflow.config.extensions_config import ( reload_extensions_config, validate_raw_extensions_config, ) -from deerflow.config.runtime_paths import project_root from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT from deerflow.mcp.cache import ( McpReconciliationPending, @@ -1303,7 +1302,7 @@ def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict: # same sidecar path for the complete read-modify-write cycle. config_path = ExtensionsConfig.resolve_config_path() if config_path is None: - config_path = project_root() / "extensions_config.json" + config_path = ExtensionsConfig.default_config_path() logger.info(f"No existing extensions config found. Creating new config at: {config_path}") with extensions_config_write_lock, extensions_config_file_lock(config_path): @@ -1401,7 +1400,7 @@ def _mcp_config_path(*, create: bool) -> Path: config_path = ExtensionsConfig.resolve_config_path() if config_path is None: if create: - config_path = project_root() / "extensions_config.json" + config_path = ExtensionsConfig.default_config_path() logger.info("No existing extensions config found. Creating new config at: %s", config_path) else: raise HTTPException( diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index b604b754c3..0d663c8139 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -1056,7 +1056,7 @@ def _write_extensions_skill_state( projection_update = skill_projection_mutation(storage, "public", remove_names=removal_names) if rebuild_public_projection and isinstance(storage, LocalSkillStorage) else nullcontext() config_path = ExtensionsConfig.resolve_config_path() if config_path is None: - config_path = Path.cwd().parent / "extensions_config.json" + config_path = ExtensionsConfig.default_config_path() logger.info(f"No existing extensions config found. Creating new config at: {config_path}") from deerflow.skills.mutations.guard import managed_global_state_write diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 63583e05d3..1fb3009265 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -112,8 +112,10 @@ can become stale or inaccessible when a host editor replaces a file on save. Configuration priority: 1. Explicit `config_path` argument 2. `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable -3. `extensions_config.json` in current directory (backend/) -4. `extensions_config.json` in parent directory (project root - **recommended location**) +3. `extensions_config.json`, then legacy `mcp_config.json`, in the project root (`DEER_FLOW_PROJECT_ROOT`, else the current directory) - **recommended location** +4. Legacy `backend/` and repository-root copies, located from the harness source tree + +When the search finds nothing, the Gateway skill toggle and MCP writers create the file at `ExtensionsConfig.default_config_path()` (the project root, step 3), so the next resolution reads back what they wrote; `DeerFlowClient` raises instead of creating one. Extensions are optional only in the fallback *search* mode (priority 3-4 above): `ExtensionsConfig.resolve_config_path()` returns `None` when neither an explicit `config_path` nor `DEER_FLOW_EXTENSIONS_CONFIG_PATH` is given and the search locations find nothing. An explicit `config_path` argument or a set `DEER_FLOW_EXTENSIONS_CONFIG_PATH` (priority 1-2) is an operator assertion that one particular file must be used, so a missing file in either of those modes raises `FileNotFoundError` instead — including when the file existed earlier and has since been deleted. The MCP tools cache's staleness check (`deerflow.mcp.cache._resolve_config_path`) is a narrow, deliberate exception to that rule: it catches that `FileNotFoundError` locally and treats it as "unconfigured" so a previously-valid config disappearing mid-run degrades the cache to serving its last-known-good tools instead of raising out of a per-request hot path (see the MCP System section below). diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index ddf3f9a001..346713c07d 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validat from deerflow.config._boolean_guards import reject_boolean from deerflow.config.file_signature import ConfigSignature, get_config_signature, read_config_with_signature -from deerflow.config.runtime_paths import existing_project_file +from deerflow.config.runtime_paths import existing_project_file, project_root from deerflow.constants import ( DEFAULT_MCP_SESSION_INIT_TIMEOUT, MCP_TASK_NAME_MAX_LENGTH, @@ -515,6 +515,18 @@ class ExtensionsConfig(BaseModel): # return None rather than raising. return None + @classmethod + def default_config_path(cls) -> Path: + """Where a writer creates ``extensions_config.json`` when none resolves. + + The project root is the first location the fallback search of + :meth:`resolve_config_path` checks, so a file created here is the one + the next resolution — and every other writer — reads back. Only for + writers that create the file after ``resolve_config_path()`` returned + ``None``; the explicit and env-var modes never reach it. + """ + return project_root() / "extensions_config.json" + @classmethod def from_file(cls, config_path: str | None = None) -> "ExtensionsConfig": """Load extensions config from JSON file. diff --git a/backend/tests/test_mcp_config_secrets.py b/backend/tests/test_mcp_config_secrets.py index ce23867351..9e80cc2757 100644 --- a/backend/tests/test_mcp_config_secrets.py +++ b/backend/tests/test_mcp_config_secrets.py @@ -1845,7 +1845,7 @@ async def test_mcp_create_without_existing_config_uses_resolvable_project_root(o return expected_path if expected_path.exists() else None monkeypatch.chdir(working_dir) - monkeypatch.setattr(mcp_router, "project_root", lambda: project_dir, raising=False) + monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_dir)) monkeypatch.setattr(mcp_router.ExtensionsConfig, "resolve_config_path", resolve_created_config) monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: SimpleNamespace(skills={})) monkeypatch.setattr(mcp_router, "reload_extensions_config", lambda: ExtensionsConfig.from_file(str(expected_path))) @@ -1863,6 +1863,58 @@ async def test_mcp_create_without_existing_config_uses_resolvable_project_root(o assert not (working_dir.parent / "extensions_config.json").exists() +@pytest.mark.asyncio +@pytest.mark.parametrize("first_writer", ["skill", "mcp"]) +async def test_skill_toggle_and_mcp_create_share_one_new_config(first_writer, monkeypatch, tmp_path): + """With no config anywhere, the first skill toggle and the first MCP edit + must create and then reuse ONE file that resolution reads back.""" + from app.gateway.routers import skills as skills_router + from deerflow.config import extensions_config as extensions_config_module + + project_dir = tmp_path / "project" + working_dir = tmp_path / "runtime" / "nested" + project_dir.mkdir() + working_dir.mkdir(parents=True) + for name in ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_EXTENSIONS_CONFIG_PATH", "DEER_FLOW_HOME", "DEER_FLOW_SKILLS_PATH"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_dir)) + monkeypatch.chdir(working_dir) + # Real resolver, but its legacy search anchored on an empty fake checkout + # so a developer's own extensions_config.json can never be found or written. + fake_module_file = tmp_path / "fake-backend" / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py" + fake_module_file.parent.mkdir(parents=True) + fake_module_file.write_text("", encoding="utf-8") + monkeypatch.setattr(extensions_config_module, "__file__", str(fake_module_file)) + assert ExtensionsConfig.resolve_config_path() is None + + monkeypatch.setattr(skills_router, "get_extensions_config", lambda: ExtensionsConfig()) + monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None) + monkeypatch.setattr(mcp_router, "get_extensions_config", lambda: SimpleNamespace(skills={})) + monkeypatch.setattr(mcp_router, "reload_extensions_config", lambda: ExtensionsConfig.from_file()) + monkeypatch.setattr(mcp_router, "reset_mcp_tools_cache", lambda: None) + + async def _create_mcp_server() -> None: + body = McpConfigUpdateRequest(mcp_servers={"added": McpServerConfigResponse(command="npx")}) + await create_mcp_servers(_request_with_role("admin"), body) + + def _toggle_skill() -> None: + skills_router._write_extensions_skill_state(None, "demo-skill", False, rebuild_public_projection=False) + + if first_writer == "skill": + _toggle_skill() + await _create_mcp_server() + else: + await _create_mcp_server() + _toggle_skill() + + created = project_dir / "extensions_config.json" + assert ExtensionsConfig.resolve_config_path() == created + persisted = json.loads(created.read_text(encoding="utf-8")) + assert persisted["skills"] == {"demo-skill": {"enabled": False}} + assert set(persisted["mcpServers"]) == {"added"} + assert sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("extensions_config.json")) == ["project/extensions_config.json"] + + @pytest.mark.asyncio @pytest.mark.parametrize("operation", ["bulk", "disable", "delete"]) async def test_frozen_task_server_mutation_rejected_before_persistence(operation, monkeypatch, tmp_path): diff --git a/backend/tests/test_runtime_paths.py b/backend/tests/test_runtime_paths.py index a9a608198c..a2ea689fab 100644 --- a/backend/tests/test_runtime_paths.py +++ b/backend/tests/test_runtime_paths.py @@ -181,6 +181,30 @@ def test_extensions_config_falls_back_to_legacy_when_project_root_lacks_file(tmp assert ExtensionsConfig.resolve_config_path() == legacy_extensions +def test_extensions_config_default_path_is_the_first_search_location(tmp_path: Path, monkeypatch): + """A writer that finds no extensions config must create it where the + search looks first, so the next resolution reads the file it wrote.""" + _clear_path_env(monkeypatch) + project = tmp_path / "project" + project.mkdir() + cwd = tmp_path / "runtime" / "backend" + cwd.mkdir(parents=True) + monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project)) + monkeypatch.chdir(cwd) + + fake_paths_module_file = tmp_path / "fake-backend-empty" / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py" + fake_paths_module_file.parent.mkdir(parents=True) + fake_paths_module_file.write_text("", encoding="utf-8") + monkeypatch.setattr(extensions_config_module, "__file__", str(fake_paths_module_file)) + + assert ExtensionsConfig.resolve_config_path() is None + created = ExtensionsConfig.default_config_path() + assert created == project / "extensions_config.json" + + created.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8") + assert ExtensionsConfig.resolve_config_path() == created + + def test_extensions_config_explicit_path_missing_file_raises(tmp_path: Path, monkeypatch): """An explicit ``config_path`` argument pointing at a file that does not exist (e.g. it existed earlier and was deleted) must raise diff --git a/backend/tests/test_skills_custom_router.py b/backend/tests/test_skills_custom_router.py index cef9fb7f37..59b5421484 100644 --- a/backend/tests/test_skills_custom_router.py +++ b/backend/tests/test_skills_custom_router.py @@ -1160,12 +1160,15 @@ def test_public_skill_toggle_clears_all_users_cache(monkeypatch, tmp_path): assert persisted["skills"]["public-skill"]["enabled"] is False -def test_public_skill_toggle_creates_missing_extensions_config(monkeypatch, tmp_path): +def test_public_skill_toggle_creates_missing_extensions_config_at_project_root(monkeypatch, tmp_path): from deerflow.config.extensions_config import ExtensionsConfig - backend_dir = tmp_path / "backend" - backend_dir.mkdir() + # The working directory's parent is not the project root: the new file + # must land where resolution looks first, not next to the cwd. + backend_dir = tmp_path / "runtime" / "backend" + backend_dir.mkdir(parents=True) monkeypatch.chdir(backend_dir) + monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path)) config_path = tmp_path / "extensions_config.json" def _load_skills(*, enabled_only: bool): @@ -1201,6 +1204,7 @@ def test_public_skill_toggle_creates_missing_extensions_config(monkeypatch, tmp_ assert json.loads(config_path.read_text(encoding="utf-8")) == { "skills": {"public-skill": {"enabled": False}}, } + assert not (backend_dir.parent / "extensions_config.json").exists() def test_public_skill_toggle_rebuilds_projection_before_response(monkeypatch, tmp_path):