mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
feat(extensions): add example guide and packaging checks (#6755)
* feat(extensions): add example guide and packaging checks * fix(extensions): align packaging checks with installer Reject Windows drive-qualified wheel paths before extraction and reuse the installer entry-point validator. Cover unsafe paths, dotted attributes, and valid dotted modules in regression tests. * fix(extensions): skip unused wheel extraction Only extract wheels when browser manifests need validation, with a backend-only regression test. Link the three remaining example READMEs in the developer guide. * docs(frontend): trim guidance intro below size budget --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
1b7ca5ecf1
commit
0b78051840
143
backend/tests/test_extension_check_script.py
Normal file
143
backend/tests/test_extension_check_script.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Packaging checks catch defects before a trusted extension is installed."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "check_extension.py"
|
||||
spec = importlib.util.spec_from_file_location("extension_check_script", SCRIPT)
|
||||
checker = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(checker)
|
||||
|
||||
|
||||
def wheel(
|
||||
tmp_path,
|
||||
*,
|
||||
entry="example = example:install",
|
||||
requirement="deerflow-extension-api>=0.2,<0.3",
|
||||
files=None,
|
||||
):
|
||||
path = tmp_path / "example-0.1.0-py3-none-any.whl"
|
||||
with ZipFile(path, "w") as archive:
|
||||
archive.writestr(
|
||||
"example-0.1.0.dist-info/METADATA",
|
||||
f"Metadata-Version: 2.3\nName: example\nVersion: 0.1.0\nRequires-Dist: {requirement}\n",
|
||||
)
|
||||
archive.writestr(
|
||||
"example-0.1.0.dist-info/entry_points.txt",
|
||||
f"[deerflow.extensions]\n{entry}\n",
|
||||
)
|
||||
archive.writestr(
|
||||
"example/__init__.py",
|
||||
"raise RuntimeError('The checker must not import plugin code')\n",
|
||||
)
|
||||
for name, content in (files or {}).items():
|
||||
archive.writestr(name, content)
|
||||
return path
|
||||
|
||||
|
||||
def test_packaged_entry_is_checked_without_importing_plugin(tmp_path):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path))
|
||||
|
||||
|
||||
def test_backend_only_wheel_skips_extraction(tmp_path, monkeypatch):
|
||||
def fail_if_extracted(*args, **kwargs):
|
||||
pytest.fail("Backend-only wheels must not be extracted")
|
||||
|
||||
monkeypatch.setattr(ZipFile, "extractall", fail_if_extracted)
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entry", "message"),
|
||||
[
|
||||
("example = missing:install", "entry module"),
|
||||
("example = example", "entry point target"),
|
||||
("example = example:factory.install", "entry point target"),
|
||||
("example = example:install\nsecond = example:install", "exactly one"),
|
||||
],
|
||||
)
|
||||
def test_invalid_packaged_entry_is_rejected(tmp_path, entry, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, entry=entry))
|
||||
|
||||
|
||||
def test_dotted_entry_module_is_accepted_without_importing_plugin(tmp_path):
|
||||
checker.check_wheel(
|
||||
tmp_path,
|
||||
wheel(tmp_path, entry="example = example.nested:install", files={"example/nested.py": "raise RuntimeError('The checker must not import plugin code')\n"}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requirement",
|
||||
[
|
||||
"unrelated>=1",
|
||||
"deerflow-extension-api>=99",
|
||||
'deerflow-extension-api>=0.2; extra == "optional"',
|
||||
],
|
||||
)
|
||||
def test_missing_or_incompatible_api_dependency_is_rejected(tmp_path, requirement):
|
||||
with pytest.raises(ValueError, match="extension-api"):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, requirement=requirement))
|
||||
|
||||
|
||||
def test_dropped_manifest_is_detected_from_src_layout(tmp_path):
|
||||
package = tmp_path / "src" / "example"
|
||||
package.mkdir(parents=True)
|
||||
(package / "ui_manifest.json").write_text("{}", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="manifest.*not packaged"):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path))
|
||||
|
||||
|
||||
def test_packaged_assets_use_host_manifest_validation(tmp_path):
|
||||
manifest = json.dumps({"schema_version": 1, "entry": "main.mjs", "files": ["main.mjs", "styles.css"]})
|
||||
files = {
|
||||
"example/ui_manifest.json": manifest,
|
||||
"example/main.mjs": "export default {};",
|
||||
"example/styles.css": "body {}",
|
||||
}
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, files=files))
|
||||
del files["example/styles.css"]
|
||||
with pytest.raises(FileNotFoundError):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, files=files))
|
||||
|
||||
|
||||
def test_custom_manifest_can_be_checked_explicitly(tmp_path):
|
||||
path = "example/resources.json"
|
||||
manifest = json.dumps({"schema_version": 1, "entry": "main.mjs", "files": ["main.mjs"]})
|
||||
checker.check_wheel(
|
||||
tmp_path,
|
||||
wheel(tmp_path, files={path: manifest, "example/main.mjs": "export default {};"}),
|
||||
manifests=[path],
|
||||
)
|
||||
|
||||
|
||||
def test_wheel_root_manifest_is_also_validated(tmp_path):
|
||||
with pytest.raises(ValueError, match="Unsupported browser asset manifest"):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, files={"ui_manifest.json": "{}"}))
|
||||
|
||||
|
||||
def test_single_module_source_cannot_drop_its_manifest(tmp_path):
|
||||
(tmp_path / "example.py").write_text("", encoding="utf-8")
|
||||
(tmp_path / "ui_manifest.json").write_text("{}", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="manifest.*not packaged"):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../escaped", "/escaped", r"example\escaped", "C:/ui_manifest.json", "C:ui_manifest.json", "C:/example/ui_manifest.json", "C:example/ui_manifest.json"])
|
||||
def test_wheel_paths_cannot_escape_temporary_directory(tmp_path, monkeypatch, name):
|
||||
def fail_if_extracted(*args, **kwargs):
|
||||
pytest.fail("Unsafe wheel paths reached extraction")
|
||||
|
||||
monkeypatch.setattr(ZipFile, "extractall", fail_if_extracted)
|
||||
with pytest.raises(ValueError, match="wheel path"):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, files={name: "no"}))
|
||||
|
||||
|
||||
def test_invalid_packaged_python_is_rejected(tmp_path):
|
||||
with pytest.raises(SyntaxError):
|
||||
checker.check_wheel(tmp_path, wheel(tmp_path, files={"example/broken.py": "def broken(:\n"}))
|
||||
58
examples/README.md
Normal file
58
examples/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Develop an extension from an existing example
|
||||
|
||||
Start with the smallest example that covers your extension. Each directory is an
|
||||
independent Python package using the public `deerflow-extension-api` contract.
|
||||
|
||||
| Start here | What it demonstrates |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Extension example](deerflow-extension-example/README.md) | Middleware, task lifecycle, model observations, services, and HTTP routes. Start here for backend extensions. |
|
||||
| [Conversation bookmarks](deerflow-extension-bookmarks/README.md) | A browser page, conversation action, backend actions, packaged resources, and a model tool. Start here for full-stack plugins. |
|
||||
| [Agent teams](deerflow-extension-agent-teams/README.md) | Full Agent orchestration. Use this after the basic contribution and lifecycle patterns are familiar. |
|
||||
|
||||
Additional examples cover [text-list classification](deerflow-extension-jev-classify/README.md),
|
||||
[context pruning](deerflow-extension-jev-context/README.md), and
|
||||
[advisory screening of fetched content](deerflow-extension-jev-screening/README.md).
|
||||
|
||||
## Edit, test, check, then install
|
||||
|
||||
1. Copy the selected example outside the DeerFlow checkout. Rename its Python
|
||||
package, distribution and `deerflow.extensions` entry point. For a full-stack
|
||||
plugin, also choose its own namespace and browser module name.
|
||||
2. Follow the example's README to configure its dependencies and run its tests.
|
||||
Keep package behavior tests alongside the extension.
|
||||
3. From the DeerFlow checkout root, prepare the backend environment with
|
||||
`make -C backend install`, then check the package:
|
||||
|
||||
```bash
|
||||
uv run --project backend --no-sync python scripts/check_extension.py /absolute/path/to/your-extension
|
||||
```
|
||||
|
||||
To try the checker on an unchanged example:
|
||||
|
||||
```bash
|
||||
uv run --project backend --no-sync python scripts/check_extension.py examples/deerflow-extension-bookmarks
|
||||
```
|
||||
|
||||
4. Follow that example's deployment and try-it instructions. Restart Gateway,
|
||||
refresh the browser when applicable, and exercise the installed feature.
|
||||
Use `make extension-upgrade SOURCE=/absolute/path/to/your-extension` to apply
|
||||
later source edits: local installs are snapshots.
|
||||
|
||||
## What the checker covers
|
||||
|
||||
The script builds a wheel in a temporary directory, checks exactly one extension
|
||||
entry point and its packaged Python module, parses packaged Python syntax, and
|
||||
checks that the API dependency range accepts this checkout's contract version.
|
||||
It validates conventional `ui_manifest.json` resources with the same validator
|
||||
used by Gateway, including detecting manifests left out of the wheel in flat or
|
||||
`src/` layouts. A custom manifest filename needs an explicit wheel-relative path:
|
||||
|
||||
```bash
|
||||
uv run --project backend --no-sync python scripts/check_extension.py /absolute/path/to/your-extension --manifest your_package/resources.json
|
||||
```
|
||||
|
||||
The build executes the package's build backend, so check trusted sources. The
|
||||
checker does not import the extension, call `install()`, install the extension, or
|
||||
change deployment configuration. Registration/configuration behavior and the
|
||||
`@extension` version marker are verified when loading the plugin; continue to run
|
||||
the package tests and the installed feature after packaging checks pass.
|
||||
@ -1,5 +1,8 @@
|
||||
# Conversation bookmarks — independent plugin example
|
||||
|
||||
For the shared edit → test → packaging check → install workflow, see the
|
||||
[existing-example guide](../README.md).
|
||||
|
||||
A Pi-inspired example of a complete feature in one package: save the last visible
|
||||
assistant answer from a conversation menu, then search, rename, read or delete
|
||||
bookmarks in its own **My bookmarks** page, reached from the workspace sidebar.
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
# DeerFlow extension example
|
||||
|
||||
For the shared edit → test → packaging check → install workflow, see the
|
||||
[existing-example guide](../README.md).
|
||||
|
||||
This directory is a compact, standalone Python package showing five of the
|
||||
eight DeerFlow extension contribution kinds: middleware, task lifecycle,
|
||||
system-model observer, service, and router. It depends on the public
|
||||
|
||||
@ -9,6 +9,22 @@ import { Callout, Steps } from "nextra/components";
|
||||
|
||||
This chapter walks through a complete extension named `hello`. It contributes one middleware that times every tool call and logs a warning when a call is slower than a configured threshold. By the end you will have packaged it, unit-tested it without DeerFlow, installed it into a checkout, and seen it run.
|
||||
|
||||
## Start from an existing example
|
||||
|
||||
To adapt working code, start with `examples/deerflow-extension-example` for backend
|
||||
hooks or `examples/deerflow-extension-bookmarks` for a browser feature with backend
|
||||
actions and a model tool. The [example guide](https://github.com/bytedance/deer-flow/blob/main/examples/README.md)
|
||||
links their tests and installation steps. After preparing the backend environment,
|
||||
run the packaging check from the checkout root:
|
||||
|
||||
```bash
|
||||
uv run --project backend --no-sync python scripts/check_extension.py /absolute/path/to/your-extension
|
||||
```
|
||||
|
||||
It builds and checks a temporary wheel without importing the extension or changing
|
||||
deployment configuration. Continue with the example's tests and installed-feature
|
||||
verification. To build a minimal middleware from scratch, follow the tutorial below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A DeerFlow checkout that runs with `make dev`. See [Quick Start](/docs/application/quick-start).
|
||||
|
||||
@ -9,6 +9,20 @@ import { Callout, Steps } from "nextra/components";
|
||||
|
||||
本章完整走一遍一个名为 `hello` 的扩展。它贡献一个中间件,为每次工具调用计时,调用慢于配置阈值时记录一条警告。读完你会把它打包、在不依赖 DeerFlow 的情况下做单元测试、安装进一个 checkout,并看到它运行。
|
||||
|
||||
## 从现有示例开始
|
||||
|
||||
如果要修改已有代码,后端扩展从 `examples/deerflow-extension-example` 开始;
|
||||
带页面、后端 action 和模型工具的插件从 `examples/deerflow-extension-bookmarks` 开始。
|
||||
[示例上手路径](https://github.com/bytedance/deer-flow/blob/main/examples/README.md)
|
||||
列出了对应的测试和安装步骤。准备好后端环境后,在 checkout 根目录运行打包检查:
|
||||
|
||||
```bash
|
||||
uv run --project backend --no-sync python scripts/check_extension.py /absolute/path/to/your-extension
|
||||
```
|
||||
|
||||
脚本会构建并检查临时 wheel,不导入扩展,也不修改部署配置。
|
||||
随后继续运行示例测试,并验证安装后的功能。若要从零写一个最小中间件,接着阅读下面的教程。
|
||||
|
||||
## 前提
|
||||
|
||||
- 一个能用 `make dev` 运行的 DeerFlow checkout。见[快速上手](/docs/application/quick-start)。
|
||||
|
||||
@ -25,11 +25,13 @@ path variables and compare storage defaults with the launcher's actual shell blo
|
||||
|
||||
## Dependency Check Diagnostics
|
||||
|
||||
`check.py` captures tool output as UTF-8 with replacement for malformed bytes,
|
||||
independently of the host locale. Its Python pnpm runner inherits the environment
|
||||
with `PYTHONIOENCODING=utf-8:backslashreplace`, matching the capture encoding.
|
||||
Keep Unicode failure diagnostics and exit status available to `make check`.
|
||||
Real subprocess regressions live in `backend/tests/test_check_script.py`.
|
||||
`check_extension.py` checks wheels with Gateway's asset validator.
|
||||
Tests: `backend/tests/test_extension_check_script.py`.
|
||||
|
||||
`check.py` decodes captured output as UTF-8 with replacement and runs pnpm with
|
||||
`PYTHONIOENCODING=utf-8:backslashreplace`. Preserve Unicode diagnostics and exit
|
||||
status in `make check`, independently of the host locale.
|
||||
Tests: `backend/tests/test_check_script.py`.
|
||||
|
||||
## Manual Claude OAuth Export
|
||||
|
||||
|
||||
176
scripts/check_extension.py
Normal file
176
scripts/check_extension.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""Build and inspect a trusted extension wheel without installing its code.
|
||||
|
||||
Run from the checkout root with the backend environment:
|
||||
uv run --project backend --no-sync python scripts/check_extension.py PACKAGE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import configparser
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Sequence
|
||||
from email.parser import Parser
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from deerflow_extension_api import API_VERSION, BrowserAssets
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
from deerflow.extensions.browser_assets import load_browser_assets
|
||||
from deerflow.extensions.manager import _validate_entry_point
|
||||
|
||||
|
||||
def check_wheel(source: Path, wheel: Path, *, manifests: Sequence[str] = ()) -> None:
|
||||
with (
|
||||
ZipFile(wheel) as archive,
|
||||
tempfile.TemporaryDirectory(prefix="deerflow-extension-wheel-") as temporary,
|
||||
):
|
||||
names = archive.namelist()
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("Duplicate wheel paths")
|
||||
for name in names:
|
||||
path = PurePosixPath(name)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or PureWindowsPath(name).drive
|
||||
or ".." in path.parts
|
||||
or "\\" in name
|
||||
):
|
||||
raise ValueError("Invalid wheel path")
|
||||
|
||||
metadata_paths = [
|
||||
name for name in names if name.endswith(".dist-info/METADATA")
|
||||
]
|
||||
if len(metadata_paths) != 1:
|
||||
raise ValueError("Expected one wheel distribution")
|
||||
metadata = Parser().parsestr(archive.read(metadata_paths[0]).decode("utf-8"))
|
||||
requirements = [
|
||||
Requirement(value) for value in metadata.get_all("Requires-Dist", [])
|
||||
]
|
||||
api = [
|
||||
requirement
|
||||
for requirement in requirements
|
||||
if canonicalize_name(requirement.name) == "deerflow-extension-api"
|
||||
and (
|
||||
requirement.marker is None or requirement.marker.evaluate({"extra": ""})
|
||||
)
|
||||
]
|
||||
if not api or any(
|
||||
API_VERSION not in requirement.specifier for requirement in api
|
||||
):
|
||||
raise ValueError(
|
||||
f"Declare a deerflow-extension-api dependency compatible with this checkout ({API_VERSION})"
|
||||
)
|
||||
|
||||
entry_path = str(PurePosixPath(metadata_paths[0]).parent / "entry_points.txt")
|
||||
entries = configparser.ConfigParser(interpolation=None)
|
||||
entries.optionxform = str
|
||||
if entry_path in names:
|
||||
entries.read_string(archive.read(entry_path).decode("utf-8"))
|
||||
if (
|
||||
not entries.has_section("deerflow.extensions")
|
||||
or len(entries["deerflow.extensions"]) != 1
|
||||
):
|
||||
raise ValueError("Declare exactly one deerflow.extensions entry point")
|
||||
entry_name, target = next(iter(entries["deerflow.extensions"].items()))
|
||||
_validate_entry_point(entry_name, target)
|
||||
module, _ = target.rsplit(":", 1)
|
||||
module_path = module.replace(".", "/")
|
||||
if not any(
|
||||
name in names
|
||||
for name in (f"{module_path}.py", f"{module_path}/__init__.py")
|
||||
):
|
||||
raise ValueError("Extension entry module is not packaged")
|
||||
for name in names:
|
||||
if name.endswith(".py"):
|
||||
ast.parse(archive.read(name), filename=name)
|
||||
|
||||
# Existing examples use flat or src-layout packages and ui_manifest.json.
|
||||
# Custom manifest names are explicit rather than guessing from plugin code.
|
||||
manifest_paths = set(manifests)
|
||||
top_module = module.split(".")[0]
|
||||
for import_root in (source, source / "src"):
|
||||
package = import_root / top_module
|
||||
if package.is_dir():
|
||||
manifest_paths.update(
|
||||
path.relative_to(import_root).as_posix()
|
||||
for path in package.rglob("ui_manifest.json")
|
||||
)
|
||||
elif (
|
||||
package.with_suffix(".py").is_file()
|
||||
and (import_root / "ui_manifest.json").is_file()
|
||||
):
|
||||
manifest_paths.add("ui_manifest.json")
|
||||
manifest_paths.update(
|
||||
name for name in names if PurePosixPath(name).name == "ui_manifest.json"
|
||||
)
|
||||
for manifest in manifest_paths:
|
||||
if manifest not in names:
|
||||
raise ValueError(f"Browser manifest is not packaged: {manifest}")
|
||||
if manifest_paths:
|
||||
archive.extractall(temporary)
|
||||
for manifest in sorted(manifest_paths):
|
||||
path = Path(temporary) / manifest
|
||||
load_browser_assets(BrowserAssets("check", path.parent, manifest=path.name))
|
||||
|
||||
print(f"Entry point: {entry_name} = {target}")
|
||||
print(f"API dependency: compatible with {API_VERSION}")
|
||||
print(f"Browser manifests: {len(manifest_paths)} checked")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and check a trusted Python extension package using the current DeerFlow contract."
|
||||
)
|
||||
parser.add_argument(
|
||||
"package", type=Path, help="directory containing pyproject.toml"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
action="append",
|
||||
default=[],
|
||||
help="custom browser manifest path inside the wheel (repeatable)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
source = args.package.resolve()
|
||||
if not (source / "pyproject.toml").is_file():
|
||||
raise ValueError("Package directory must contain pyproject.toml")
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="deerflow-extension-check-"
|
||||
) as temporary:
|
||||
result = subprocess.run(
|
||||
["uv", "build", str(source), "--wheel", "--out-dir", temporary],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ValueError(f"Wheel build failed:\n{result.stderr}")
|
||||
wheels = list(Path(temporary).glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise ValueError("Build must produce one wheel")
|
||||
check_wheel(source, wheels[0], manifests=args.manifest)
|
||||
print(f"Packaging checks passed: {wheels[0].name}")
|
||||
return 0
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
SyntaxError,
|
||||
BadZipFile,
|
||||
configparser.Error,
|
||||
subprocess.TimeoutExpired,
|
||||
) as exc:
|
||||
print(f"Extension check failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user