2026-01-20 13:57:36 +08:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
refactor: split backend into harness (deerflow.*) and app (app.*) (#1131)
* refactor: extract shared utils to break harness→app cross-layer imports
Move _validate_skill_frontmatter to src/skills/validation.py and
CONVERTIBLE_EXTENSIONS + convert_file_to_markdown to src/utils/file_conversion.py.
This eliminates the two reverse dependencies from client.py (harness layer)
into gateway/routers/ (app layer), preparing for the harness/app package split.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: split backend/src into harness (deerflow.*) and app (app.*)
Physically split the monolithic backend/src/ package into two layers:
- **Harness** (`packages/harness/deerflow/`): publishable agent framework
package with import prefix `deerflow.*`. Contains agents, sandbox, tools,
models, MCP, skills, config, and all core infrastructure.
- **App** (`app/`): unpublished application code with import prefix `app.*`.
Contains gateway (FastAPI REST API) and channels (IM integrations).
Key changes:
- Move 13 harness modules to packages/harness/deerflow/ via git mv
- Move gateway + channels to app/ via git mv
- Rename all imports: src.* → deerflow.* (harness) / app.* (app layer)
- Set up uv workspace with deerflow-harness as workspace member
- Update langgraph.json, config.example.yaml, all scripts, Docker files
- Add build-system (hatchling) to harness pyproject.toml
- Add PYTHONPATH=. to gateway startup commands for app.* resolution
- Update ruff.toml with known-first-party for import sorting
- Update all documentation to reflect new directory structure
Boundary rule enforced: harness code never imports from app.
All 429 tests pass. Lint clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add harness→app boundary check test and update docs
Add test_harness_boundary.py that scans all Python files in
packages/harness/deerflow/ and fails if any `from app.*` or
`import app.*` statement is found. This enforces the architectural
rule that the harness layer never depends on the app layer.
Update CLAUDE.md to document the harness/app split architecture,
import conventions, and the boundary enforcement test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add config versioning with auto-upgrade on startup
When config.example.yaml schema changes, developers' local config.yaml
files can silently become outdated. This adds a config_version field and
auto-upgrade mechanism so breaking changes (like src.* → deerflow.*
renames) are applied automatically before services start.
- Add config_version: 1 to config.example.yaml
- Add startup version check warning in AppConfig.from_file()
- Add scripts/config-upgrade.sh with migration registry for value replacements
- Add `make config-upgrade` target
- Auto-run config-upgrade in serve.sh and start-daemon.sh before starting services
- Add config error hints in service failure messages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix comments
* fix: update src.* import in test_sandbox_tools_security to deerflow.*
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle empty config and search parent dirs for config.example.yaml
Address Copilot review comments on PR #1131:
- Guard against yaml.safe_load() returning None for empty config files
- Search parent directories for config.example.yaml instead of only
looking next to config.yaml, fixing detection in common setups
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: correct skills root path depth and config_version type coercion
- loader.py: fix get_skills_root_path() to use 5 parent levels (was 3)
after harness split, file lives at packages/harness/deerflow/skills/
so parent×3 resolved to backend/packages/harness/ instead of backend/
- app_config.py: coerce config_version to int() before comparison in
_check_config_version() to prevent TypeError when YAML stores value
as string (e.g. config_version: "1")
- tests: add regression tests for both fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: update test imports from src.* to deerflow.*/app.* after harness refactor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:55:52 +08:00
|
|
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
|
|
|
|
from deerflow.skills import Skill, load_skills
|
refactor: extract shared skill installer and upload manager to harness (#1202)
* refactor: extract shared skill installer and upload manager to harness
Move duplicated business logic from Gateway routers and Client into
shared harness modules, eliminating code duplication.
New shared modules:
- deerflow.skills.installer: 6 functions (zip security, extraction, install)
- deerflow.uploads.manager: 7 functions (normalize, deduplicate, validate,
list, delete, get_uploads_dir, ensure_uploads_dir)
Key improvements:
- SkillAlreadyExistsError replaces stringly-typed 409 status routing
- normalize_filename rejects backslash-containing filenames
- Read paths (list/delete) no longer mkdir via get_uploads_dir
- Write paths use ensure_uploads_dir for explicit directory creation
- list_files_in_dir does stat inside scandir context (no re-stat)
- install_skill_from_archive uses single is_file() check (one syscall)
- Fix agent config key not reset on update_mcp_config/update_skill
Tests: 42 new (22 installer + 20 upload manager) + client hardening
* refactor: centralize upload URL construction and clean up installer
- Extract upload_virtual_path(), upload_artifact_url(), enrich_file_listing()
into shared manager.py, eliminating 6 duplicated URL constructions across
Gateway router and Client
- Derive all upload URLs from VIRTUAL_PATH_PREFIX constant instead of
hardcoded "mnt/user-data/uploads" strings
- Eliminate TOCTOU pre-checks and double file read in installer — single
ZipFile() open with exception handling replaces is_file() + is_zipfile()
+ ZipFile() sequence
- Add missing re-exports: ensure_uploads_dir in uploads/__init__.py,
SkillAlreadyExistsError in skills/__init__.py
- Remove redundant .lower() on already-lowercase CONVERTIBLE_EXTENSIONS
- Hoist sandbox_uploads_dir(thread_id) before loop in uploads router
* fix: add input validation for thread_id and filename length
- Reject thread_id containing unsafe filesystem characters (only allow
alphanumeric, hyphens, underscores, dots) — prevents 500 on inputs
like <script> or shell metacharacters
- Reject filenames longer than 255 bytes (OS limit) in normalize_filename
- Gateway upload router maps ValueError to 400 for invalid thread_id
* fix: address PR review — symlink safety, input validation coverage, error ordering
- list_files_in_dir: use follow_symlinks=False to prevent symlink metadata
leakage; check is_dir() instead of exists() for non-directory paths
- install_skill_from_archive: restore is_file() pre-check before extension
validation so error messages match the documented exception contract
- validate_thread_id: move from ensure_uploads_dir to get_uploads_dir so
all entry points (upload/list/delete) are protected
- delete_uploaded_file: catch ValueError from thread_id validation (was 500)
- requires_llm marker: also skip when OPENAI_API_KEY is unset
- e2e fixture: update TitleMiddleware exclusion comment (kept filtering —
middleware triggers extra LLM calls that add non-determinism to tests)
* chore: revert uv.lock to main — no dependency changes in this PR
* fix: use monkeypatch for global config in e2e fixture to prevent test pollution
The e2e_env fixture was calling set_title_config() and
set_summarization_config() directly, which mutated global singletons
without automatic cleanup. When pytest ran test_client_e2e.py before
test_title_middleware_core_logic.py, the leaked enabled=False caused
5 title tests to fail in CI.
Switched to monkeypatch.setattr on the module-level private variables
so pytest restores the originals after each test.
* fix: address code review — URL encoding, API consistency, test isolation
- upload_artifact_url: percent-encode filename to handle spaces/#/?
- deduplicate_filename: mutate seen set in place (caller no longer
needs manual .add() — less error-prone API)
- list_files_in_dir: document that size is int, enrich stringifies
- e2e fixture: monkeypatch _app_config instead of set_app_config()
to prevent global singleton pollution (same pattern as title/summarization fix)
- _make_e2e_config: read LLM connection details from env vars so
external contributors can override defaults
- Update tests to match new deduplicate_filename contract
* docs: rewrite RFC in English and add alternatives/breaking changes sections
* fix: address code review feedback on PR #1202
- Rename deduplicate_filename to claim_unique_filename to make
the in-place set mutation explicit in the function name
- Replace PermissionError with PathTraversalError(ValueError) for
path traversal detection — malformed input is 400, not 403
* fix: set _app_config_is_custom in e2e test fixture to prevent config.yaml lookup in CI
---------
Co-authored-by: greatmengqi <chenmengqi.0376@bytedance.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com>
2026-03-25 16:28:33 +08:00
|
|
|
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
|
2026-01-20 13:57:36 +08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-03-13 21:27:54 +08:00
|
|
|
|
2026-01-20 13:57:36 +08:00
|
|
|
router = APIRouter(prefix="/api", tags=["skills"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkillResponse(BaseModel):
|
|
|
|
|
"""Response model for skill information."""
|
|
|
|
|
|
|
|
|
|
name: str = Field(..., description="Name of the skill")
|
|
|
|
|
description: str = Field(..., description="Description of what the skill does")
|
|
|
|
|
license: str | None = Field(None, description="License information")
|
|
|
|
|
category: str = Field(..., description="Category of the skill (public or custom)")
|
|
|
|
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkillsListResponse(BaseModel):
|
|
|
|
|
"""Response model for listing all skills."""
|
|
|
|
|
|
|
|
|
|
skills: list[SkillResponse]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkillUpdateRequest(BaseModel):
|
|
|
|
|
"""Request model for updating a skill."""
|
|
|
|
|
|
|
|
|
|
enabled: bool = Field(..., description="Whether to enable or disable the skill")
|
|
|
|
|
|
|
|
|
|
|
2026-01-31 22:10:05 +08:00
|
|
|
class SkillInstallRequest(BaseModel):
|
|
|
|
|
"""Request model for installing a skill from a .skill file."""
|
|
|
|
|
|
|
|
|
|
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
|
|
|
|
|
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkillInstallResponse(BaseModel):
|
|
|
|
|
"""Response model for skill installation."""
|
|
|
|
|
|
|
|
|
|
success: bool = Field(..., description="Whether the installation was successful")
|
|
|
|
|
skill_name: str = Field(..., description="Name of the installed skill")
|
|
|
|
|
message: str = Field(..., description="Installation result message")
|
|
|
|
|
|
|
|
|
|
|
2026-01-20 13:57:36 +08:00
|
|
|
def _skill_to_response(skill: Skill) -> SkillResponse:
|
|
|
|
|
"""Convert a Skill object to a SkillResponse."""
|
|
|
|
|
return SkillResponse(
|
|
|
|
|
name=skill.name,
|
|
|
|
|
description=skill.description,
|
|
|
|
|
license=skill.license,
|
|
|
|
|
category=skill.category,
|
|
|
|
|
enabled=skill.enabled,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/skills",
|
|
|
|
|
response_model=SkillsListResponse,
|
|
|
|
|
summary="List All Skills",
|
|
|
|
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
|
|
|
|
)
|
|
|
|
|
async def list_skills() -> SkillsListResponse:
|
|
|
|
|
try:
|
|
|
|
|
skills = load_skills(enabled_only=False)
|
|
|
|
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/skills/{skill_name}",
|
|
|
|
|
response_model=SkillResponse,
|
|
|
|
|
summary="Get Skill Details",
|
|
|
|
|
description="Retrieve detailed information about a specific skill by its name.",
|
|
|
|
|
)
|
|
|
|
|
async def get_skill(skill_name: str) -> SkillResponse:
|
|
|
|
|
try:
|
|
|
|
|
skills = load_skills(enabled_only=False)
|
|
|
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
|
|
|
|
|
|
if skill is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
|
|
|
|
|
|
return _skill_to_response(skill)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put(
|
|
|
|
|
"/skills/{skill_name}",
|
|
|
|
|
response_model=SkillResponse,
|
|
|
|
|
summary="Update Skill",
|
2026-03-11 10:03:01 +08:00
|
|
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
2026-01-20 13:57:36 +08:00
|
|
|
)
|
|
|
|
|
async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse:
|
|
|
|
|
try:
|
|
|
|
|
skills = load_skills(enabled_only=False)
|
|
|
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
|
|
|
|
|
|
if skill is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
|
|
|
|
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
|
|
|
if config_path is None:
|
|
|
|
|
config_path = Path.cwd().parent / "extensions_config.json"
|
|
|
|
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
|
|
|
|
|
|
|
|
|
extensions_config = get_extensions_config()
|
|
|
|
|
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
|
|
|
|
|
|
|
|
|
config_data = {
|
|
|
|
|
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
|
|
|
|
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 16:53:12 +08:00
|
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
2026-01-20 13:57:36 +08:00
|
|
|
json.dump(config_data, f, indent=2)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
|
|
|
|
reload_extensions_config()
|
|
|
|
|
|
|
|
|
|
skills = load_skills(enabled_only=False)
|
|
|
|
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
|
|
|
|
|
|
if updated_skill is None:
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
|
|
|
|
return _skill_to_response(updated_skill)
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
2026-01-31 22:10:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/skills/install",
|
|
|
|
|
response_model=SkillInstallResponse,
|
|
|
|
|
summary="Install Skill",
|
|
|
|
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
|
|
|
|
)
|
|
|
|
|
async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
|
|
|
|
try:
|
2026-02-09 12:55:12 +08:00
|
|
|
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
refactor: extract shared skill installer and upload manager to harness (#1202)
* refactor: extract shared skill installer and upload manager to harness
Move duplicated business logic from Gateway routers and Client into
shared harness modules, eliminating code duplication.
New shared modules:
- deerflow.skills.installer: 6 functions (zip security, extraction, install)
- deerflow.uploads.manager: 7 functions (normalize, deduplicate, validate,
list, delete, get_uploads_dir, ensure_uploads_dir)
Key improvements:
- SkillAlreadyExistsError replaces stringly-typed 409 status routing
- normalize_filename rejects backslash-containing filenames
- Read paths (list/delete) no longer mkdir via get_uploads_dir
- Write paths use ensure_uploads_dir for explicit directory creation
- list_files_in_dir does stat inside scandir context (no re-stat)
- install_skill_from_archive uses single is_file() check (one syscall)
- Fix agent config key not reset on update_mcp_config/update_skill
Tests: 42 new (22 installer + 20 upload manager) + client hardening
* refactor: centralize upload URL construction and clean up installer
- Extract upload_virtual_path(), upload_artifact_url(), enrich_file_listing()
into shared manager.py, eliminating 6 duplicated URL constructions across
Gateway router and Client
- Derive all upload URLs from VIRTUAL_PATH_PREFIX constant instead of
hardcoded "mnt/user-data/uploads" strings
- Eliminate TOCTOU pre-checks and double file read in installer — single
ZipFile() open with exception handling replaces is_file() + is_zipfile()
+ ZipFile() sequence
- Add missing re-exports: ensure_uploads_dir in uploads/__init__.py,
SkillAlreadyExistsError in skills/__init__.py
- Remove redundant .lower() on already-lowercase CONVERTIBLE_EXTENSIONS
- Hoist sandbox_uploads_dir(thread_id) before loop in uploads router
* fix: add input validation for thread_id and filename length
- Reject thread_id containing unsafe filesystem characters (only allow
alphanumeric, hyphens, underscores, dots) — prevents 500 on inputs
like <script> or shell metacharacters
- Reject filenames longer than 255 bytes (OS limit) in normalize_filename
- Gateway upload router maps ValueError to 400 for invalid thread_id
* fix: address PR review — symlink safety, input validation coverage, error ordering
- list_files_in_dir: use follow_symlinks=False to prevent symlink metadata
leakage; check is_dir() instead of exists() for non-directory paths
- install_skill_from_archive: restore is_file() pre-check before extension
validation so error messages match the documented exception contract
- validate_thread_id: move from ensure_uploads_dir to get_uploads_dir so
all entry points (upload/list/delete) are protected
- delete_uploaded_file: catch ValueError from thread_id validation (was 500)
- requires_llm marker: also skip when OPENAI_API_KEY is unset
- e2e fixture: update TitleMiddleware exclusion comment (kept filtering —
middleware triggers extra LLM calls that add non-determinism to tests)
* chore: revert uv.lock to main — no dependency changes in this PR
* fix: use monkeypatch for global config in e2e fixture to prevent test pollution
The e2e_env fixture was calling set_title_config() and
set_summarization_config() directly, which mutated global singletons
without automatic cleanup. When pytest ran test_client_e2e.py before
test_title_middleware_core_logic.py, the leaked enabled=False caused
5 title tests to fail in CI.
Switched to monkeypatch.setattr on the module-level private variables
so pytest restores the originals after each test.
* fix: address code review — URL encoding, API consistency, test isolation
- upload_artifact_url: percent-encode filename to handle spaces/#/?
- deduplicate_filename: mutate seen set in place (caller no longer
needs manual .add() — less error-prone API)
- list_files_in_dir: document that size is int, enrich stringifies
- e2e fixture: monkeypatch _app_config instead of set_app_config()
to prevent global singleton pollution (same pattern as title/summarization fix)
- _make_e2e_config: read LLM connection details from env vars so
external contributors can override defaults
- Update tests to match new deduplicate_filename contract
* docs: rewrite RFC in English and add alternatives/breaking changes sections
* fix: address code review feedback on PR #1202
- Rename deduplicate_filename to claim_unique_filename to make
the in-place set mutation explicit in the function name
- Replace PermissionError with PathTraversalError(ValueError) for
path traversal detection — malformed input is 400, not 403
* fix: set _app_config_is_custom in e2e test fixture to prevent config.yaml lookup in CI
---------
Co-authored-by: greatmengqi <chenmengqi.0376@bytedance.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com>
2026-03-25 16:28:33 +08:00
|
|
|
result = install_skill_from_archive(skill_file_path)
|
|
|
|
|
return SkillInstallResponse(**result)
|
|
|
|
|
except FileNotFoundError as e:
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
except SkillAlreadyExistsError as e:
|
|
|
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
2026-01-31 22:10:05 +08:00
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to install skill: {e}", exc_info=True)
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|