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:
Wenchao An 2026-10-12 22:51:20 +08:00 committed by GitHub
parent 1b7ca5ecf1
commit 0b78051840
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 420 additions and 5 deletions

View 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
View 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.

View File

@ -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.

View File

@ -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

View File

@ -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).

View File

@ -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)。

View File

@ -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
View 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())