114 Commits

Author SHA1 Message Date
Parthiban Sivakumar
065f84f711
fix(doctor): skip tool checks when tools block is empty (#5301)
* fix(doctor): skip tool checks when tools block is empty

Follow-up to #5296, which fixed this for `models:`. The same defect
remains for `tools:`: `.get("tools", [])` returns None when the key is
present but empty, because the default only applies when the key is
absent. Iterating that None raises TypeError, which the surrounding
broad handler renders as a check result:

    ! web search configured  ('NoneType' object is not iterable)
    ! web fetch configured  ('NoneType' object is not iterable)
    ! web capture configured  ('NoneType' object is not iterable)
    ! image search configured  ('NoneType' object is not iterable)
    ✗ sandbox configured  ('NoneType' object is not iterable)

Line 476 is reached by all four web/image checks through the shared
check_web_tool helper, and line 645 by check_sandbox.

Unlike the models case, a default install does not hit this: `make
config` ships ten real tool entries, so a user has to empty or comment
out that block first.

The web checks now fall through to their normal "no tool in config"
warning and the sandbox check evaluates normally. Parentheses on the
comprehension are for readability; `or` already binds correctly there.

Regression tests use the commented-out `tools:` shape that reproduces
the failure, matching the tests added in #5296.

Fixes #5300

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(doctor): skip non-mapping tool entries, tighten regression tests

Review follow-ups on the line this PR already changes.

A `tools:` list holding a scalar (`tools:\n  - web_search`) reached
`t.get("name")` and raised AttributeError, which the broad handler
rendered as the check result:

    ! web search configured  ('str' object has no attribute 'get')

That is the same leakage this PR removes for the null case, so it is
fixed here rather than deferred. `check_sandbox` already guards the same
way via `isinstance(tool, dict)`.

The empty-tools test asserted that "NoneType" was absent from the
detail, which pins the failure mode rather than the behaviour — it would
still pass if the detail became some other internal error text. Both
tests now assert the expected message directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(doctor): assert sandbox outcome instead of the failure mode

Review follow-up, same class as the web-tool assertion fixed earlier in
this PR. The sandbox regression test still asserted that "NoneType" was
absent from the detail, which pins the failure mode rather than the
outcome — it would keep passing if some other internal error text leaked
out of the broad handler.

On this config the path is deterministic: an empty `tools:` means no
bash tool, so exactly one result. Assert the fields directly
(`CheckResult` has no `__eq__`, so whole instances cannot be compared by
value).

Verified against `main`'s scripts/doctor.py, where the same config
yields status=fail and detail="'NoneType' object is not iterable", so
the new assertions are red there and green here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:29:52 +08:00
Parthiban Sivakumar
611801d5c0
fix(doctor): skip LLM checks when models block is empty (#5296)
`config.example.yaml` ships a `models:` key with every entry commented
out, so it parses as None rather than an empty list and the `[]` default
in `.get("models", [])` never applies. Iterating that None raised
TypeError, which the surrounding broad handler rendered as a check
result:

    ✗ LLM API key check  ('NoneType' object is not iterable)
    ✗ LLM auth check  ('NoneType' object is not iterable)
    ✗ LLM package check  ('NoneType' object is not iterable)

Every fresh install hit this before configuring a model, turning one
actionable error into four and hiding the real "models configured" hint
behind internal exception text.

Fall back on a falsy value at the three iteration sites so the checks
return no results when nothing is configured. `check_models_configured`
gets the same treatment for consistency; it was already correct because
it tests truthiness rather than iterating.

The existing tests missed this because they use `models: []`, an
explicit empty list, which iterates fine. The added regression tests use
the commented-out shape that `make config` actually produces.

`make doctor` now reports 1 error instead of 4 on a fresh clone.

Fixes #5295

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-08 20:01:39 +08:00
Yusuf Gürdoğan
23bd76046a
feat(community): add Sofya web search provider (#5239)
* feat(community): add Sofya web search provider

Add a community provider backed by Sofya (https://sofya.co). Its search
endpoint returns the content of the result pages, not only their snippets,
and its fetch endpoint returns a page as markdown. Both are plain JSON over
HTTP, so this needs no extra Python package (uses httpx, already a
dependency).

Changes:
- backend/packages/harness/deerflow/community/sofya/__init__.py
- backend/packages/harness/deerflow/community/sofya/tools.py
  Implements web_search_tool and web_fetch_tool using httpx.
  API key is read from the config.yaml `api_key` field or the SOFYA_API_KEY
  env var. Follows the same interface and output shape as the existing
  ddg_search and serper providers, including the max_results parameter with
  config override and the structured "No results found" error.
- backend/tests/test_sofya_tools.py
  Unit tests covering API key resolution, config overrides, result mapping,
  time range, HTTP errors, empty results, and fetch failures.
- config.example.yaml: add commented-out Sofya web_search and web_fetch
  examples alongside the other providers
- .env.example: add SOFYA_API_KEY placeholder
- backend/docs/CONFIGURATION.md: list Sofya under web_search, web_fetch and
  the environment variables

* fix(sofya): honor caller max_results, validate search_depth, join time_range contract test

- Caller-supplied max_results now wins; config is used only when the
  argument is omitted, matching GroundRoute.
- search_depth is clamped to basic/snippets; an unsupported value logs a
  warning and falls back to basic.
- Sofya added to the shared time_range schema contract test.

* fix(sofya): cap per-result content so a search stays inline

An unbounded search payload (up to 20 read pages) crossed the tool output
budget middleware's externalize_min_chars threshold, which replaces the
result list with a file reference. Cap each result's content at
contents_max_characters (default 2000, 0 disables), matching Exa's config
key. Five capped results stay under the 12000 char threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

* fix(sofya): list Sofya in the recency contract, coerce non-string content

_clip subscripted its input, so a non-string content or description from
the API raised TypeError instead of degrading. Coerce to text first, the
way _sofya_post and _response_results guard the shapes around it. Also add
Sofya to the Web Search Recency section in backend/AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

* fix(sofya): coerce web_fetch content, list sofya in the tools guide, add changelog

web_fetch sliced its content the same way web_search did before the last
push: a truthy non-string from the API passed the falsiness guard and then
raised TypeError. Reuse _clip, keeping the `or ""` so empty content still
reports "No content found".

Also add sofya to the community provider inventory in
packages/harness/deerflow/tools/AGENTS.md and an [Unreleased] changelog entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

* docs(zh): add the missing InfoQuest and Firecrawl web_fetch tabs

The ZH web_fetch tab list named five providers where EN names seven. Both
tabs mirror their EN counterparts, so the two locales list the same
web_fetch providers again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-08 10:50:03 +08:00
pclin
fcb1c88e5e
fix(scripts): probe _pick_python candidates through env so make dev starts the frontend on Windows (#5181)
* bugfix #5179

* test: cover the env-aware _pick_python fallback from #5179

Follow the test_serve_nginx_stop.py extraction pattern: drive the real
_pick_python from serve.sh against a stub-only PATH plus a mocked env.

- python3 succeeds directly but fails through env -> python selected
  (red on main, green on this branch)
- env rejects every candidate -> nonzero exit (also red on main)
- healthy PATH with the real env -> python3 preferred, guarding against
  over-rejection

MSYS/Git Bash hosts need the stub dir as an MSYS-style (/c/...) PATH
entry, and bash diagnostics may arrive in the console code page, so the
runner decodes output with errors="replace".
2026-09-04 23:32:40 +08:00
Willem Jiang
eac028cca6
ci: preauthorize skill review waiver hashes (#5143) 2026-09-02 16:54:23 +08:00
Willem Jiang
1af79c7bcf
fix(ci):resolve the skill_review errors (#5121)
* fix(ci):resolve the skill_review errors

* fix(ci): split skill creator fixes from waiver rollout
2026-08-31 23:17:39 +08:00
PeaceMaker-best
72ba661b84
feat(skills): install local skill archives (#5039)
* feat(skills): install local skill archives

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

* fix(skills): enforce upload limits before parsing

* fix(nginx): scope skill upload limit to upload route

* fix(nginx): harden skill upload proxy handling

* fix(skills): improve archive upload feedback

* fix(skills): address upload review polish

---------

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
2026-08-31 15:22:45 +08:00
早上肚子疼
b41354d75f
fix(scripts): invoke repo shell scripts through an explicit interpreter (#5031)
Recipes and scripts ran sibling shell scripts bare (./scripts/x.sh), so
any checkout that lost the executable bit -- zip/tarball download,
core.fileMode=false, non-POSIX filesystem -- failed with:

    make: ./scripts/docker.sh: Permission denied
    make: *** [Makefile:181: docker-start] Error 127

The tracked modes are already 100755, so chmod cannot fix it. Name the
interpreter instead: the POSIX branch of RUN_SHELL_SCRIPT (renamed from
RUN_WITH_GIT_BASH) now expands to $(BASH) rather than nothing, and the
five script-to-script call sites are prefixed with bash.

Fixes #2903

Co-authored-by: zaoshangduziteng <309590849+zaoshangduziteng@users.noreply.github.com>
2026-08-29 14:54:02 +08:00
Nan Gao
bf3e792a6a
feat(models): add GLM-5.3-Flash thinking workaround (#5074) 2026-08-28 22:24:10 +08:00
zhang
23d8e4b3a3
feat(scripts): support skipping frontend build on make start (#5053)
* feat(scripts): support skipping frontend build on make start

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(scripts): validate skip-frontend-build before stop_all and format test

---------

Co-authored-by: PoetryLin <PoetryLin@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-28 10:44:30 +08:00
Serply
4dbfe37ff3
feat(community): add Serply web search tool (#5023)
Add deerflow.community.serply.tools:web_search_tool, a Google SERP
provider for the web_search slot that also covers Google News and Google
Scholar through an optional `vertical` config option. Reads the key from
api_key in config.yaml or SERPLY_API_KEY, clamps max_results to Serply's
1-100 range, and returns the same structured JSON errors as the Serper
and Brave tools.

Register the provider in config.example.yaml, scripts/doctor.py,
scripts/wizard/providers.py, .env.example, backend/docs/CONFIGURATION.md,
the en/zh tools.mdx provider tabs, and tools/AGENTS.md. Tests mock httpx.
2026-08-28 10:30:30 +08:00
yong
846c716523
feat(search): add Tencent Cloud WSA provider (#5057)
* feat(search): add Tencent Cloud WSA provider

* docs: restore README to upstream

* docs: remove README changes from WSA provider PR

* fix(doctor): validate Tencent WSA API key
2026-08-27 18:12:05 +08:00
YZJF,YCDG,DJLY,ZZZB
851e76661b
fix(docker): don't abort Docker startup when .env is missing (#4956)
* fix(docker): create compose env files and keep Windows compose paths relative

Windows Docker reports a generic file-not-found when env_file targets are missing, or when compose paths are doubled. Make docker-start copy .env examples and invoke compose with filenames relative to docker/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docker): make dev compose env files optional and repair test fixture

Address review feedback on #4956.

[P1] prepare_compose_env aborted before the mocked COMPOSE_CMD in
test_compose_commands_set_deer_flow_root_before_compose, because the
temp root had no compose file or .env examples. Seed them in the
fixture so the preflight reaches the mock.

[P2] .env is gitignored, so a fresh clone has none and a direct
`docker compose -f docker/docker-compose-dev.yaml up --build` aborts on
Windows before scripts/docker.sh can help. Mark the dev env_file entries
`required: false` so a missing .env is not fatal, and document that
direct Compose must be run from the repository root.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docker): declare Compose 2.24 floor and keep non-start commands read-only

Address the second review round on #4956.

- Document Compose >= 2.24 (CONTRIBUTING, README, compose header) and fail
  early from make docker-start with an actionable message; probe both
  `docker compose` and the hyphenated `docker-compose` binary.
- Document DEER_FLOW_ROOT for direct Compose callers (bash + PowerShell);
  leave the variable without a $PWD fallback because PowerShell/cmd do not
  export it.
- Split prepare_compose_env: compose_preflight is shared and read-only;
  ensure_env_files runs only from start.
- Expand tests for version boundaries, hyphenated fallback, env-file
  creation, and read-only stop/logs/restart behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docker): reuse the probed Compose binary for wrapper operations

The version probe could accept a standalone docker-compose install while
COMPOSE_CMD stayed hardcoded to `docker compose`, so preflight passed and
start/logs/stop/restart then failed. Keep the selected executable in
COMPOSE_BIN (array), refresh COMPOSE_CMD from it in the current shell, and
extend the fallback test through an actual stop invocation.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:48:37 +08:00
Willem Jiang
41b3c17447
fix(ci): fix the Agents.md size check test error (#4978) 2026-08-24 08:52:01 +08:00
Airene Fang
b47c7838a5
chore: Extend frontend startup timeout from 120s to 300s. (#4899) 2026-08-19 22:06:16 +08:00
Willem Jiang
13fe06ee67
doc(agent): update the AGENTS.md and ARCHITECTURE.md (#4817)
* doc(agent): update the AGENTS.md and ARCHITECTURE.md

* increase the ROOT Agents.md size

* Fixed the unit test errors
2026-08-14 23:17:33 +08:00
Aari
5d520e44a8
fix(docker): wait for gateway readiness (#4806)
* fix(docker): verify gateway startup readiness

* fix(docker): clarify compose wait requirement
2026-08-14 11:07:23 +08:00
Nan Gao
c542185a7f
feat(extensions): add gateway contribution points and packaged extension management (#4780)
* feat(extensions): add gateway services and routers

* feat(extensions): add standalone reference extension

* fix(extensions): harden contributed gateway routes

* docs(extensions): document gateway contribution points

* feat(extensions): add operator CLI for packaged extension management

Add `deerflow extensions install/list/enable/disable/remove` plus the root
`make extension-*` wrappers, backed by an `ExtensionManager` that owns one
transaction over backend/pyproject.toml, backend/uv.lock, the managed source
snapshot, the uv environment, and the `plugins:` block in config.yaml.

Install accepts a package requirement, a public HTTPS Git URL, or a local
directory. Local directories are copied to backend/extensions/sources/ as
deployable snapshots rather than editable installs, and the root .dockerignore
re-includes that tree so snapshots reach the backend builder. Remote sources are
HTTPS-only; SSH Git, file:// and local wheels are rejected because the stock
Docker builder cannot reproduce them.

Because environment configuration can still resolve a plain package name to a
local wheel (a UV_FIND_LINKS wheelhouse, say), every uv add/remove is followed
by an audit of the new lock: any local reference the stock image build cannot
reproduce rolls back the whole transaction. A config carrying duplicate
top-level `plugins:` keys is rejected outright rather than managed against one
block while the Gateway reads another.

Dependency synchronization now has one lock authority. The `extensions`
dependency group joins [tool.uv].default-groups, every startup path syncs the
same lock with --locked and launches with --no-sync, and the Docker images move
to uv 0.11.1 for the --no-workspace boundary the manager needs.

Loader gains `enabled`, `name` and `package` fields so a disabled extension is
skipped before resolution and import.

Co-authored-by: Codex <codex@openai.com>

* fix(extensions): stop the managed plugins rewrite from destroying config

Two data-safety defects in the managed `plugins:` block writer.

The "next top-level key" boundary was a regex matching only
`[A-Za-z_][A-Za-z0-9_-]*` or a quoted key. `AppConfig` is `extra="allow"`, so a
config may legally carry any top-level key, and a key the pattern cannot
recognize did not fail loudly — it read as "no next section", and the rewrite
replaced that neighbour and its entire subtree with the managed block. `my.key`,
`2fa`, `$schema`, `my key` and non-ASCII keys were all silently deleted by a
plain `extension-enable`/`disable`. Both boundaries now come from the YAML
parser's node marks, so key shape is irrelevant.

The file-final branch never consulted the trailing-comment scan the has-next-key
branch used, so any comment below the block was dropped. Since the manager
appends `plugins:` at end of file, that is the steady-state shape for most
installs: an operator note below the block was destroyed on the next toggle.

Separately, every managed install wrote `required: true` while the loader
defaults to false. That turned any later load failure — broken wheel, missing
native library, deleted snapshot — into a Gateway startup abort recoverable only
with shell access. New records are now written `required: false`, with an
explicit `install --required` opt-in; adopting an existing hand-written record
still preserves the operator's own choice.

* fix(extensions): harden the manager transaction and correct its docs

Follow-up hardening on the extension package manager.

Security posture, which the docs already claimed:
- Scrub `UV_PYTHON`, `UV_INSECURE_HOST`, `UV_CONSTRAINT` and
  `UV_NO_BUILD_ISOLATION` from the controlled uv environment. `UV_PYTHON` swaps
  the interpreter that the entry-point probe then imports and calls, and every
  later `uv run --no-sync` startup uses; `UV_INSECURE_HOST` removes the TLS
  validation the HTTPS-only source rule depends on. Neither is an index, proxy,
  cache or credential-provider setting, so neither was covered by the carve-out.
- Recognize run-together and all-caps secret query parameters (`accesstoken`,
  `ACCESSTOKEN`, `key`, `pw`, `sas`, `code`). The camel-case splitter only fires
  on case transitions, so only the separated spellings were caught. Short
  generic words stay boundary-anchored, so `?keyword=` remains installable.
- Validate the config before running any uv command. `uv add`/`uv sync` execute
  the package's build backend, so a config the manager could never write to must
  fail before that code runs rather than afterwards through rollback.

Transaction integrity:
- Run the second dependency-file restore from a `finally`. The recovery sync
  runs without `--locked` when the checkout had no lock, so uv writes one while
  resolving; if that sync then failed, the restore was skipped and the operator
  kept a lock file they never had. A failing recovery sync now also reports the
  original failure instead of replacing it.
- Skip the recovery sync on cancellation. Answering Ctrl-C with a full
  dependency resolve invites a second interrupt that escapes the handler and
  strands the checkout mid-transaction; the declarations are already restored
  and the next locked startup sync reconciles the environment.
- Retry a non-blocking lock on Windows instead of using `msvcrt.LK_LOCK`, which
  gives up after ~10s — far shorter than a real `uv add` plus `uv sync`, so
  contention surfaced as `Permission denied` rather than serializing.
- Locate the entry-point probe's JSON payload instead of parsing stdout's first
  line, so a `sitecustomize`/`.pth` banner cannot roll back a good install.
- Warn when the lock records a loopback source. `127.0.0.1` inside the image
  builder is a different machine, but unlike an environment-driven wheelhouse
  resolution this is a source the operator typed deliberately, so it is reported
  rather than rolled back. Private-network indexes are untouched: a builder on
  that network can reach them.

Docs: the blanket claim that failed operations restore the config file was
wrong — the conflict branches deliberately preserve a concurrent external edit
and leave `remove` deactivated. Document that, the `required: false` default,
the config preflight, the interrupt behaviour, and where the plugins-block
boundaries come from.

* test(gateway): pin the request-path projection agreement

`get_request_route_path()` imports the private
`starlette._utils.get_route_path` so the auth and CSRF predicates classify
the exact string Starlette's router matches on. Its requirement is not
"strip root_path correctly" but "return what the dispatcher is matching",
so delegating to the router's own implementation keeps the two in lockstep
by construction. Keep the private import rather than vendoring a copy: an
import that disappears fails loudly at startup, while a stale copy diverges
silently at a security boundary.

Cover the property directly instead of the mechanism, so the tests survive
a future reimplementation:

- projection edge cases, including the segment-boundary guard that keeps
  root_path="/api" from slicing "/apifoo/models" into a string the router
  would never match
- agreement with the router under nested mounts
- the two bypasses these predicates exist to prevent: a protected route
  mounted under the "/health" public prefix must still 401, and a POST
  mounted under "/api/webhooks" must still require a CSRF token

Both are verified to fail when the projection is reverted to
`request.url.path` (9/13 red) and when a plausible vendored copy omits the
boundary guard (the 2 boundary cases red).

Declare starlette as a bounded direct dependency so a bump — which is
security-relevant here — shows up in review rather than arriving silently
through FastAPI.

* ci: pin uv to the version production ships

ExtensionManager is not a consumer of uv the build tool -- it is a program
whose whole job is driving `uv` as a subprocess, depending on its CLI
behavior (`--no-workspace`, `--no-sync`, what `uv add` writes into
`[dependency-groups] extensions`) and on the `uv.lock` serialization format.
uv is closer to a runtime dependency with a contract than to incidental
tooling.

backend/Dockerfile pins that binary to 0.11.1, but all eight
astral-sh/setup-uv steps installed whatever was latest at run time, so CI
exercised the manager against a uv that is not the uv production runs. The
sharpest failure that allows: a newer uv bumps uv.lock's `revision`, CI
stays green because the same uv reads back what it wrote, and the pinned uv
in the production image cannot read the committed lock. `uv lock --check`
is version-sensitive for the same reason -- it verifies the lock is what
*this* uv would produce, and two versions can emit equivalent but
non-identical output.

Pin every step to 0.11.1 and lift the one lingering setup-uv@v3 to v7 so
the steps share input and caching behavior.

Pinning alone drifts apart again on the next bump, so add a constraint test
in the style of test_compose_default_bind_host.py: the Dockerfile's
UV_IMAGE tag is the single source of truth, and both compose defaults plus
every setup-uv step must match it. Verified to fail when a pin drifts, when
a step omits `version`, and -- the real scenario -- when the Dockerfile is
bumped alone, which lights up the workflows and both compose files at once.

* fix(gateway): state the extension route auth limit and abort a failed dev sync

Two scoped review follow-ups.

README: contributed routers cannot enter the host's reserved public prefixes,
which makes every extension endpoint session-authenticated -- there is no way
to expose an unauthenticated route. The rejection rule was documented but its
consequence was not, so inbound provider webhooks and public status endpoints
read as merely undocumented rather than out of scope for this release.

docker/dev-entrypoint.sh: the self-heal retry reuses `--locked`, so it repairs
a corrupt .venv but never a lock that disagrees with pyproject.toml. `set -e`
already stopped the script there -- uvicorn was not being started against a
stale environment -- but it exited on a bare uv exit code with no indication of
what to do. Abort explicitly with the cause and the fix.

Tests slice the sync block out of the real script and run it against a stub uv,
so they exercise the shipped code rather than a copy of it (/app/backend only
exists inside the container). They cover the success path, the retry that
recovers, the abort, and the guidance. Verified against the pre-fix script:
only the guidance case goes red, confirming the abort itself was already
correct.

* fix(extensions): point Git SSH shorthand at the HTTPS correction

Git's SCP-like shorthand carries no URL scheme, so `git+git@host:org/repo.git`
reached the scheme rules looking like a bare path and was rejected with
"local path references are not deployable; pass a local directory so DeerFlow
can snapshot it". The operator asked for a remote source, so that guidance
points at the wrong fix. Detect the shorthand ahead of the scheme rules and
report the public-HTTPS correction instead.

The bare `git@host:org/repo.git` spelling took a different wrong turn: packaging
parses it as a direct reference named `git`, leaving `host:org/repo.git`, whose
`host` reads as a URL scheme and produced the generic HTTPS message. Both
spellings now share one message, as does the PEP 508 named form.

* docs: keep the root extension summary within its new budget

#4799 split the depth out of the module guides and added a size gate; the root
file's job is now orientation, and this branch had pushed it 192 bytes past the
soft limit. The manager transaction, source rules, and lock discipline are
already stated in full in the extensions guide, so the root keeps the one-line
orientation and points there instead of restating them.

---------

Co-authored-by: Codex <codex@openai.com>
2026-08-13 23:55:30 +08:00
Ryker_Feng
ccff5f5ce7
docs: govern agent guidance size (#4799)
* docs: govern agent guidance size

* refactor: split agent guidance by code scope

* Clarify virtual path handling in AGENTS.md

Updated the translation section to clarify the role of `LocalSandboxProvider` and the handling of virtual paths in the tool layer.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-08-13 21:49:04 +08:00
MasonWight
6bb376abfd
fix: resolve diagnostic paths from any cwd (#4736)
* fix: resolve diagnostic paths from any cwd

* test: cover relative diagnostic script paths
2026-08-11 21:56:20 +08:00
ajayr
d732b90dc3
feat(channels): add Buzz (Nostr) channel connector (#4649)
* feat(channels): add Buzz (Nostr) channel connector

Adds a Buzz (https://github.com/block/buzz) channel so DeerFlow can join a
Nostr-relay workspace as a member: it answers @mentions in channels, replies
to DMs, and streams answers by editing one message in place.

  * app/channels/buzz_nostr.py — pure NIP-01 helpers: canonical event ids,
    BIP-340 signing/verification, chat/edit/auth builders, relay frames.
  * app/channels/buzz.py — BuzzChannel: one NIP-42-authenticated websocket,
    channel discovery (kind 39000) with one subscription per channel, live
    membership tracking (44100/44101), per-channel replay watermarks, and
    replies posted once then edited in place (kind 40003).
  * app/channels/buzz_run_policy.py — same-thread serialization, mirroring
    the Feishu precedent.

Inbound is gated in order: signature verification, self-drop, /connect
bind-and-return, pubkey allowlist, then mention / DM / mention-free /
thread-follow. Off by default; needs the new optional `buzz` extra
(coincurve, lazily imported), which detect_uv_extras resolves from
channels.buzz.enabled the same way it already handles channels.discord.

Two relay behaviours drove the design and are worth knowing when reviewing:
a global {"kinds":[9]} subscription receives nothing from buzz-relay and a
multi-value "#h" filter receives nothing either, so one REQ per channel is
required; and a single global `since` cursor skips quiet channels, so
watermarks are per channel.

Signed-off-by: Ajay R <ajayr@formbuddy.com>

* fix(channels): only publish assistant messages from the IM stream

`_accumulate_stream_text` decided what streamed `messages-tuple` payloads
become displayable text by rejecting ONLY payloads whose `type` contained
"tool", so it published everything else. DeerFlow writes hidden model
context into the messages channel as ordinary messages -- memory recall and
the rewritten user turn as hidden HumanMessages (DynamicContextMiddleware),
the `<durable_context_data>` block as another (DurableContextMiddleware) --
and LangGraph fans those state writes out on the messages stream, so they
reached every streaming IM channel as the assistant's reply.

Proved live on a Buzz relay: the connector published a `<memory>` fact block
and, in another run, a verbatim echo of the user's own inbound message.
Affects Feishu, Telegram, WeCom and Buzz; worst on Buzz, where each update
is an immutable public Nostr event that a corrective edit cannot unpublish.

Invert the filter to an allowlist of assistant message types. Two new pure
helpers keep it testable:

- `_stream_payload_type` resolves the type from both shapes the function
  already handles: the `model_dump()` shape the gateway emits, and
  LangChain's `to_json()` constructor shape whose own `type` is the literal
  "constructor" and whose class name is the tail of the `id` path.
- `_is_assistant_stream_type` matches "ai"/"assistant" by PREFIX, not
  substring -- ordinary words contain "ai" ("chain", "domain"), and a
  substring test would admit a foreign type name by accident.

The bare-`str` branch is removed: an untyped payload cannot be attributed to
the assistant, nothing in DeerFlow produces one (serialize_messages_tuple
always emits `[message_dict, metadata]`), and a runtime that emitted raw text
deltas would emit hidden context the same way. Per-message-id buffering and
merging are unchanged.

Tests pin both directions, including multi-chunk merging across one message
id, so the allowlist cannot silently kill streaming, plus an end-to-end
`_handle_streaming_chat` test asserting the live payload never reaches an
outbound message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ajay R <ajayr@formbuddy.com>

* chore(helm): bump config_version to 33 in chart values and README

config.example.yaml moved to 33 for the buzz channel block; the chart's
embedded config example and its README copy track it (config_version only
drives the outdated-config warning, per scripts/check_config_version.sh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Ajay R <ajayr@formbuddy.com>

---------

Signed-off-by: Ajay R <ajayr@formbuddy.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:29:22 +08:00
天魔
276481371e
fix(docker): set DEER_FLOW_ROOT for log commands (#4658)
* fix(docker): set DEER_FLOW_ROOT for log commands

* fix(docker): set root before restart

* docs(docker): clarify log command
2026-08-04 22:45:10 +08:00
Vanzeren
095092418c
fix(gateway):unify thread id validation (#4589)
* fix(gateway): unify thread ID validation at the API boundary

Thread ID entry points accepted arbitrary strings while downstream
consumers (filesystem paths, Kubernetes Provisioner, JSONL event store)
each enforced different character restrictions, so invalid IDs were
persisted first and only failed later during sandbox/workspace init.

Centralize validation in deerflow.utils.thread_id (pattern
^[A-Za-z0-9_-]{1,64}$): validate at routers, RunCreateRequest,
scheduler dispatch, paths.py, JSONL store, embedded client, and align
the Provisioner pattern (pinned by a parity test). UUIDs are still
generated only when no ID is supplied; caller-supplied opaque IDs stay
supported.

Deliberate exceptions: DELETE /threads/{id} keeps str as the legacy
cleanup escape hatch (filesystem cleanup guarded), read-only
client.get_thread stays unvalidated, and scheduler rows with legacy
invalid IDs record a failed dispatch instead of raising out of the
poll loop.

* docs: document canonical thread ID contract

README: caller-supplied thread IDs need not be UUIDs; the canonical
pattern and per-endpoint behavior. AGENTS.md: the shared
deerflow.utils.thread_id contract, its enforcement boundaries, and the
legacy-ID escape hatches.

* fix(gateway): close thread ID validation gaps at remaining entry points

Follow-up to the canonical thread ID contract: a full audit found the
uniform-422 coverage only reached about half of the thread_id surfaces.

- routers: 18 routes still took a bare thread_id: str — 13 in
  thread_runs.py (including the five messages/events/workspace-changes
  reads that returned 500 on the JSONL event store vs 404/empty on the
  DB store), 4 read routes in threads.py, and the suggestions route
  flagged in review. DELETE /api/threads/{id} keeps str as the declared
  legacy-cleanup escape hatch.
- client: upload_files/delete_upload/list_uploads/get_artifact now
  validate up front, fulfilling the RFC's 'all mutating entry points'
  clause (get_thread stays unvalidated as the declared legacy read path).
- tui: the /resume literal-ref fallback validates against the canonical
  contract and reports a descriptive error instead of failing deep in
  the client.
- scripts/support_bundle.py: replace the drifted dot-allowing pattern
  with a byte-identical copy of THREAD_ID_PATTERN (kept local so the
  script still runs with a broken venv).

* test(gateway): guard the canonical thread ID contract against regressions

- test_thread_id_route_contract.py: static AST sweep asserting every
  route handler with a thread_id parameter annotates ThreadId
  (whitelist: the DELETE escape hatch), plus a runtime sweep hitting
  all 44 thread_id routes with a non-canonical ID and asserting a 422
  that names thread_id, plus a websocket upgrade-rejection case.
- test_thread_id_validation.py: client entry-point validation,
  support_bundle pattern parity, and TUI literal-ref fallback tests.
- Align two tests that encoded the old contract (dotted IDs).
2026-08-01 19:42:44 +08:00
Nan Gao
2a143dced6
fix(docker): bind the published entry port to loopback by default (#4618)
README documents DeerFlow as deployed by default "in a local trusted
environment (accessible only via the 127.0.0.1 loopback interface)", but both
compose files published nginx as `"${PORT:-2026}:2026"`, which Docker binds to
0.0.0.0 and [::]. The shipped artifact did not match its own documented
default, so running it on a LAN or cloud host produced a wider surface than
the docs implied without the operator changing anything -- and the agent can
execute commands.

Publish as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` in both compose
files, so the default matches the documented model while operators who front
the stack with their own TLS/auth can still widen it via BIND_HOST. The
Gateway keeps binding 0.0.0.0:8001 inside the container (nginx reaches it over
the compose network) and its port stays unpublished, so the published nginx
port is the entire external surface.

BREAKING CHANGE: a deployment that relied on the previous 0.0.0.0 default
becomes unreachable from other hosts after this upgrade. Set BIND_HOST=0.0.0.0
in .env to restore it, after putting authentication in front and completing
first-run setup.

Also:
- .env.example documents BIND_HOST and PORT with the reasoning.
- deploy.sh reports the address the stack actually bound and, when it is not
  loopback, tells the operator to complete first-run setup immediately. It
  reads BIND_HOST/PORT from .env via a new read_dotenv_value helper following
  compose precedence; the shell does not source .env, so reading the
  environment alone would have reported "loopback only" for a stack .env had
  exposed. The pre-existing ${PORT} summary line had the same defect and is
  fixed with it.
- test_compose_default_bind_host.py pins the loopback default, that BIND_HOST
  stays overridable, and that no service in either compose file publishes a
  port without an explicit bind address, so a later addition cannot drift back
  to 0.0.0.0 unnoticed.
2026-08-01 08:35:02 +08:00
jinhaosong-source
d0957409f1
feat(wizard): add OrcaRouter as an LLM provider (#4598)
OrcaRouter is an OpenAI-compatible routing gateway. Mirror the existing
OpenRouter entry in the setup wizard's LLM_PROVIDERS: reuse
langchain_openai:ChatOpenAI pointed at api.orcarouter.ai/v1 with env var
ORCAROUTER_API_KEY. Default model pins a tool-capable model; orcarouter/auto
is also selectable.

Disclosure: I'm an engineer on the OrcaRouter team.

Co-authored-by: jinhaosong-source <jinhaosong@myflashcloud.com>
2026-07-31 17:07:21 +08:00
ShitK
4e44938551
fix: align pnpm consumers with Corepack fallback (#4405)
* fix: align pnpm consumers with Corepack fallback

* fix: run pnpm helper from frontend workspace

* fix: preserve Corepack resolution hint
2026-07-29 08:08:33 +08:00
nonoge
183280ebfc
fix browser extra detection for indentless YAML (#4367) 2026-07-26 09:56:17 +08:00
Ryker_Feng
fa496c0c8d
feat(browser): add agentic browser control (#4187)
* feat(browser): add agentic browser control

* fix(frontend): format browser view changes

* fix(browser): keep browser optional and isolate sidecar layout

* fix(browser): address PR review security and IME findings

- Nginx: add a browser-stream WebSocket location before the generic
  /api/threads regex so Live upgrades instead of downgrading to HTTP
  (both nginx.conf and nginx.local.conf).
- Ownership: require an existing owned thread for the WS stream and REST
  navigate, and tear down the browser session on thread deletion so a
  later caller cannot reuse a retained page/cookies by guessing the id.
- SSRF: enforce the URL policy at the browser request boundary via a
  context-level route guard covering redirects, popups, iframes, and
  subresources (skipped for CDP-attached Chrome).
- IME: skip key forwarding while a composition is active so confirming a
  CJK candidate with Enter no longer submits the remote page form.

Adds regression tests for the request guard, session teardown on delete,
and the composing-Enter key decision.

* fix(frontend): smooth streaming in long tool threads

* Revert "fix(frontend): smooth streaming in long tool threads"

This reverts commit f0462516eabe77f138d4027ea1c714fb226683cf.

* fix(browser): address review security and lifecycle findings

- Reject cross-origin WebSocket upgrades on the live browser stream
  (Origin allow-list reuse of CORS/same-origin helpers) to close a
  WS-CSRF hole, and fail closed when the ownership store is absent.
- Warn when a CDP-attached session runs with the SSRF request guard
  off, and drop the unreachable CDP screencast teardown dead code.
- Read browser session launch config from a single canonical source
  (browser_navigate) so it is deterministic regardless of call order.
- Bound per-thread Chromium accumulation with idle-timeout eviction
  and an LRU max-sessions cap.
- Reset the Live reconnect counter on a successful open so the stream
  can't permanently stall after the cumulative attempt cap.

* fix(frontend): reduce long tool thread render stalls

Reuse stable historical message groups during streaming, defer heavy Markdown and browser previews, and lazy-decode message images.

* fix(browser): keep live control responsive during continuous input

Why: Manual browser control felt laggy — a physical click ran the remote
Playwright click three times and each non-move input synchronously awaited a
JPEG screenshot, so events queued behind capture (queue wait up to ~237ms).
The first async attempt used a trailing-edge debounce, which froze the visible
page until a wheel/keyboard gesture stopped ("scroll finishes, then it jumps").

What:
- Frontend forwards one `click` per physical click instead of also emitting
  `down`/`up`, so the remote page is not clicked twice per gesture.
- Backend detaches live-frame capture from input dispatch: non-move actions
  start a rate-limited background refresh loop (leading frame + bounded cadence)
  that keeps emitting frames while input continues and never blocks dispatch.
- Add regression tests: input dispatch no longer awaits the screenshot, rapid
  inputs coalesce, and continuous input keeps refreshing before it stops.

Scenarios: Verified in the live Browser panel — a single click completes in
~57ms (was blocked behind a 171ms capture), and a 1.14s sustained wheel gesture
renders ~7 frames throughout the scroll instead of one frame after it ends.

* fix(browser): harden worker and session lifecycle

* fix(browser): address latest review feedback

* fix(frontend): preserve optimistic new-chat message

* test(e2e): preserve mocked message run ids

* fix(browser): address capability review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-21 11:46:33 +08:00
Aari
5eb59cb130
fix(sandbox): stop multi-worker orphan reconcile from killing peer sandboxes (#4221)
* fix(sandbox): stop multi-worker orphan reconcile from killing peer sandboxes

Docker sandboxes are shared across gateway workers, but each worker kept its
own in-memory warm pool. Startup reconciliation adopted every running
container, so a peer idle reaper could destroy sandboxes another worker still
owned and tool calls hit 502 / Connection refused.

Add file-based ownership leases under sandbox-leases/, only adopt true
orphans, refuse idle/replica/shutdown destroy while a foreign lease is live,
and renew the lease on create/get/release/reclaim.

Fixes #4206

* fix(sandbox): close lease fail-open, hot-path IO, and check→destroy race

Address review of the multi-worker orphan lease (#4206):

- read_lease returns None only for a genuinely-absent lease and raises
  (CorruptLeaseError/OSError) when a lease is unreadable or corrupt, so the
  ownership check fails closed instead of mistaking an unprovable peer lease
  for a free container. clear_lease still removes a stuck/corrupt file.
- get() no longer renews the lease (blocking mkdir/fsync/os.replace on the
  event loop path used by ensure_sandbox_initialized_async); active leases are
  renewed off the event loop from the idle checker (_renew_active_leases).
- The ownership check and container stop run under a per-sandbox flock guard
  (lease_ownership_guard); every lease write takes the same guard so a peer's
  touch cannot interleave with a destroy. Same-host multi-worker scope, not a
  multi-pod distributed lock.

Also fixes the ruff format lint on the branch. Adds regression tests: corrupt
and unreadable lease fail closed, a tests/blocking_io anchor keeping get()
non-blocking on the event loop, and a peer-touch/destroy interleave test.

* fix(sandbox): share container ownership across gateway instances

Rework of the #4206 fix per review: ownership state is shared through a
third-party service instead of being maintained per gateway instance,
following the stream_bridge precedent (sandbox.ownership.type:
memory | redis). The file lease and its same-host flock guard are
deleted, not ported — they only covered workers on one host, while the
deployment that hits #4206 is a load-balanced multi-instance gateway.

A lease answers "who reaps this container", not "who may use it".
Containers are deterministic per (user, thread), so consecutive turns
legitimately land on different instances: take() transfers ownership on
acquire, while claim() gates every adopt/reap path.

Leases carry a state — own: or del: — so a takeover is refused against a
teardown in progress. Without it an unconditional take() would overwrite
a destroyer's claim and the peer's container stop would land on a
sandbox the new owner had already handed to an agent.

renew() distinguishes a lapsed lease from one a peer took; only the
latter drops the sandbox. Collapsing them meant a Redis restart evicted
every in-flight sandbox on every instance at once.

Renewal runs on its own thread with a TTL derived from its interval,
never from idle_timeout: renewal used to ride the idle checker, which
does not start at idle_timeout: 0, so leases silently lapsed on a
supported config.

Ownership establishment is fail-closed: a sandbox whose ownership cannot
be published is never handed out, and a just-created container is
destroyed rather than leaked as an adoptable orphan. Every destroy path
claims before untracking.

The memory store is single-instance only and says so; the resolver reads
app_config.stream_bridge and the env var in the bridge's own order, so
deployments already using Redis get a redis ownership store without
extra config.

* fix(sandbox): wait out a recovery grace before adopting a keyless container

An absent ownership lease meant two opposite things on two paths. Renewal
reads it as LAPSED and re-establishes it: nobody took the lease, so the
container is still ours. Reconciliation read the same absent key as "orphan"
and adopted on sight.

After the store loses its keys (a Redis restart without persistence, or
eviction under maxmemory) every owner is alive and merely pre-renewal-tick.
Whichever instance reconciled first therefore adopted every live container;
each real owner's next renewal reported LOST and dropped a sandbox it was
serving mid-turn, leaving it for the adopter to idle-destroy — #4206 through
the back door, in the very case the LAPSED handling was added to make safe.
Not limited to startup: an already-running instance hits the same window from
the idle checker's periodic reconcile.

_adoptable_after_grace requires an untracked container to be seen unowned
across a full lease TTL before it can be adopted. That rebuilds the delay the
state loss erased: a live owner republishes within one renewal interval,
shorter than the TTL by construction, while a crashed owner never does, so its
containers are still adopted one grace later rather than leaking. A republished
lease resets the grace; a pausing-only timer would still expire over a live
owner's lease. The peek is read-only — the atomic claim still gates adoption.

The grace is skipped when the store cannot coordinate across processes: no peer
can hold a lease such a store would show us, so single-instance deployments
keep instant orphan cleanup, and a grace could not help a multi-worker gateway
on memory anyway.

* fix(sandbox): hold the teardown lease for as long as the container stop runs

claim(..., for_destroy=True) wrote the del: marker with the ordinary lease TTL
and nothing refreshed it. renew() extends only own: and deliberately reports a
teardown as LOST, and the destroy paths drop the sandbox from the maps the
renewal loop iterates — so a container stop that outlived the TTL let the marker
lapse, a peer's take() succeeded against the still-running container, and the
stop then landed on the turn that had just been handed it. That is the exact
window the del: state exists to close, reopened by its own expiry.

The two lease states alone never made the per-sandbox flock redundant, as I
claimed when deleting it: a held lock cannot expire, a lease can. The exclusion
has to be held deliberately rather than assumed to outlast the work it guards.

_held_teardown_lease wraps both _backend.destroy() call sites and re-claims the
marker every renewal_interval_seconds until the stop returns. No store change is
needed: claim(for_destroy=True) already refreshes an existing del: marker on
both backends.

Reachable without an abnormal backend. The schema bounds only
renewal_interval_seconds (> 0) and ttl_multiplier (>= 2), so a legal config puts
the TTL below a normal container stop; and LocalContainerBackend._stop_container
passes no timeout to subprocess.run, so a wedged daemon blocks unbounded even at
the default 120s TTL.

The TTL stays finite on purpose: the heartbeat dies with the process, so a
destroyer that crashes mid-stop still releases the container one TTL later
instead of marking it undestroyable forever.

* fix(sandbox): hold the teardown lease on every del: stop, and pin the claims that had no test

90936b49 said `_held_teardown_lease` wrapped "both" `_backend.destroy()` call
sites. There are three. `_drop_unhealthy_sandbox` marks `del:` and then blocks on
the same unbounded stop, and it untracks *before* claiming, so `_renew_owned_leases`
cannot see the id either — nothing refreshed the marker. Reproduced against a real
redis: the peer's `take()` succeeds 1.0s into a 2.5s stop. Same window, third path.

That miss came from the habit the rest of this commit addresses: a property
asserted in prose, with no test that could falsify it. Auditing every load-bearing
claim in this feature — AGENTS.md, the store docstrings, the provider's design
comments — against the test that would go red turned up several more, each
verified by mutating the code and watching the suite stay green.

Tests that could not fail:

- `test_reconcile_fails_closed_when_ownership_unknown` reached the grace gate, not
  the claim. A bare MagicMock answers `owner()` with a truthy mock, so the
  container read as peer-owned and deferred; `claim()` was never called. It stayed
  green with `_claim_ownership` failing open. Adding the grace ahead of the claim
  is what hollowed it out — inserting a gate can silently disarm the tests for
  the gate behind it.
- `test_adoption_grace_restarts_when_a_live_owner_republishes` never distinguished
  reset from pause. Those diverge only on a *second* lapse, which it never drove,
  so it passed with the reset deleted.

Claims with no test at all, each now pinned (mutation → red, per test):

- `destroy()`, `_evict_oldest_warm`, `_reclaim_warm_pool_sandbox`,
  `_register_created_sandbox` and `shutdown()`'s warm loop were each the one
  untested sibling of an "every path does X" enumeration. `shutdown()` was never
  driven with a non-empty warm pool, so a loop bypassing the ownership claim —
  stopping a live peer's container on our exit — went unnoticed.
- Renewal's unknown-is-not-lost rule, the single deliberate exception to
  fail-closed. Inverting it drops every active and warm sandbox on every instance
  the moment the store blinks.
- Both hops of the stream-bridge redis inference. Deleting either left the suite
  green while every config.yaml-native multi-instance deployment silently fell
  back to memory — #4206 reopened on exactly the deployments the inference exists
  for.

Claims narrowed instead, because they promised more than the code delivers:

- "run against both backends ... cannot drift" — CI provisions no redis, so the
  merge gate runs the memory tier only and the Lua never executes there.
- "Every destroy path claims before untracking" — `_drop_unhealthy_sandbox`
  untracks first, deliberately, under its `expected_info` TOCTOU guard.
- "Atomic: concurrent claims from different instances cannot both succeed" — true
  via Lua on redis, vacuous on the single-instance memory store, and pinned by
  neither, since the contract suite drives sequential calls. A concurrency test
  against the memory store would make the claim look covered while the mechanism
  that carries it still never runs in CI.

* fix(sandbox): release the teardown marker when a destroy() stop fails

The three `del:`-marked stop paths disagreed on failure. `_destroy_warm_entry`
releases on both outcomes and says why: the stop failed, so the container is
probably still up, and a marker left behind refuses its own thread's `take()`
until the TTL lapses. `_drop_unhealthy_sandbox` does the same. `destroy()` had no
such guard — a raising backend propagated straight past `_release_ownership`, and
the thread could not re-acquire for a full TTL.

Fails safe rather than fatal: a stuck marker stops peers from touching the
container, it is not the cross-instance kill. But the paths must agree, and this
one is the odd one out.

Release, then re-raise. Swallowing would be the easier symmetry with
`_destroy_warm_entry`'s `return False`, but `destroy()` has no failure return and
`shutdown()` logs per sandbox off the exception, so swallowing would silently
narrow what callers can see.

Found by comparing the three paths after @fancyboi999 asked for release to be
handled "consistently with the other destroy paths" on the unhealthy path — which
0d2377b2 already does. This is the sibling that wasn't.

* fix(deploy): bump chart config_version to 27 for sandbox.ownership

config.example.yaml went to 27 with the new sandbox.ownership section, but
the chart embeds its own copy and stayed at 26, so validate-chart failed.

A bare bump: the chart already sets stream_bridge.type=redis, which is what
resolve_ownership_config infers a redis ownership store from, so no field
change is needed.

* fix(sandbox): release the teardown lease from its heartbeat, not the caller

`_held_teardown_lease` joined its heartbeat only briefly and the caller
cleared the `del:` marker right after the stop. A refresh `claim` still in
flight (`RedisOwnershipStore` had no socket timeout, so a round trip could
block) could land *after* that release and rewrite `del:` on a container
whose stop had already completed — refusing a fresh `take()` (or rolling
back a fresh create) until the TTL.

Move the release into the heartbeat's own `finally`, after its loop stops,
so no refresh can run after it. The three destroy paths no longer release
after the `with` (`destroy()`'s no-container branch still does, since no
lease was held there). Bound every store round trip with a socket timeout
so the in-flight refresh — and thus the deferred release — stays finite,
and broaden the heartbeat's `except` so an unexpected error cannot strand
the marker during a long stop.

Also fold in the review follow-ups: stop re-resolving an already-resolved
ownership config in the factory, document the Redis-outage-vs-TTL boundary
in config.example.yaml, and add a tests/blocking_io anchor pinning that
`release()`'s store round trip stays off the event loop.

* fix(sandbox): refuse a non-destroy claim that would unwind our own teardown

`claim(for_destroy=False)` against our own `del:` lease fell through and
overwrote it with `own:`, cancelling a teardown that was already in flight.
The container stop cannot be recalled, so downgrading the marker would let a
`take()` hand out a container that is about to die -- #4206, self-inflicted.

No caller does this today: the two non-destroy callers run against an absent
key (the LAPSED re-claim) or an unowned one (post-grace reconcile). The
contract has to forbid it rather than rely on that staying true.

Fixed in both backends. The redis rule lives in Lua and the memory rule in
Python, so fixing one only would let them drift silently -- and the shared
contract suite is what is supposed to catch that drift, so it now covers this.

Also adds a contention test for `claim`. The suite drove sequential calls
only, so it pinned the exclusion predicate but not the atomicity that
predicate depends on; eight instances now race for one container and exactly
one must win.

* fix(sandbox): bound the container stop so it cannot outlive its teardown lease

`_stop_container` passed no `timeout` to `subprocess.run`, so a wedged
container runtime blocks it forever. The `del:` marker is what keeps a peer
from re-acquiring the container while the stop runs, but a marker is a lease
and a lease can lapse: a store outage longer than the TTL frees it, a peer's
`take()` succeeds against the still-running container, and the stop then
lands on the turn that was just handed it -- the exact #4206 failure.

The teardown heartbeat already covers the case where the store stays
reachable. This bounds the worst case independently of the ownership layer,
which is the point: it holds even when the ownership layer is the thing that
failed.

A timeout is not swallowed like a `CalledProcessError`. That error means the
runtime answered "I could not stop it"; a timeout means we do not know, and
the container is probably still running -- returning normally would let
`_destroy_warm_entry` report a clean stop and drop the warm entry, leaking a
running container nothing tracks.

* fix(sandbox): exclude this instance's own reapers from its acquire path

An ownership lease excludes peers and nothing else. `claim()` and `take()`
both succeed against our own `own:` lease by design -- that is what lets a
destroy path claim what it already owns -- so `del:` says nothing to this
process's other threads. Meanwhile every reaper decides outside `_lock`,
because a store round trip must not be held under the lock that guards every
acquire. So each reaper acts on a decision its own acquire path may already
have invalidated, and the store cannot see the difference.

Six paths end in an irreversible act (a container stop, or closing a
host-side client) on a decision made outside the lock. All six reproduce:

  _evict_oldest_warm      re-checks warm membership, then releases the lock
  _reap_expired_warm      no re-check at all
  _cleanup_idle_sandboxes re-verifies idle, then releases the lock
  _renew_owned_leases     acts on a stale renew() -> LOST
  release()               same staleness on its own refresh
  _drop_unhealthy_sandbox untracks before claiming, opening discovery

Both warm reapers are a regression from the deferred pop this branch
introduced: `WarmPoolLifecycleMixin` popped under the lock, so a reclaim's
membership check failed and the race could not occur. Deferring the pop is
still right (popping first loses the container on a refused claim), so the
exclusion has to be made explicit instead. The idle path is pre-existing in
shape, but this branch widened it from a few instructions to a network round
trip by claiming ownership before untracking.

Two guards, because the two directions want opposite answers:

Reaping -- nothing may promote it. The reaper reserves the id, and every
promote path refuses a reserved id exactly as it refuses a peer's `del:`
(drop and cold-start). The "is this still reapable?" test travels with the
reservation as a predicate and runs in the same critical section, because
checking first and reserving second is the window, not a narrower version of
it.

Forgetting -- the peer legitimately wins, so the promote is what to detect.
`_publish_ownership` bumps a per-id acquire epoch; the callers that decide
from a store round trip snapshot it first, and the pop is skipped if it
moved. Object identity cannot substitute: the reuse path re-publishes
ownership while handing out the same tracked `AioSandbox`, so an identity
check sees nothing and the pop closes a client mid-turn.

`still_reapable` is required rather than defaulting to unconditional -- the
safe default is the one that makes a new call site think about it. That
diverges from the mixin hook, which is safe because this provider overrides
both mixin callers, and loud rather than silent if those are ever dropped.

Also closes a client leak on the discover path: "nothing to roll back" was
true of the container but not of the HTTP client constructed before the
publish, which the sibling create path already closes.

The shared-store test view rebound `owner_id` outside the store's lock, so a
concurrent claim could execute under the wrong id and read its own lease as a
peer's. Serialized, so the heartbeat-hold tests stop flaking.

* fix(sandbox): mark acquire intent before the ownership round trip

A guard must become visible no later than the transition it guards. The
acquire epoch cannot manage that for `take()`: the takeover is durable before
`take()` returns -- redis has committed the SET while the reply is still in
flight -- and the epoch can only be written afterwards. In that interval the
store already says the container is ours while the epoch still reads as it
did when a renewal decided `LOST`, so the stale forget walks through, drops
the maps and closes the client the acquire is about to hand back. Acquire
then returns an id the provider no longer tracks and `get()` answers `None`
for the rest of the turn.

`_publish_ownership` now publishes an intent mark under `_lock` before the
round trip; the epoch keeps covering the other half, "an acquire completed
since you decided". `_forget_lost_sandbox` honours the intent mark
unconditionally rather than only when an epoch is supplied -- today's
epoch-less callers cannot reach the window, but "no epoch" reading as "no
guard" is how the next caller of a dangerous primitive gets written.

The same invariant had four more instances, all reproduced:

  reuse returns a decision the forget already invalidated -- before the mark
    is set a `LOST` is both current and correct, so the forget legitimately
    runs and the entry reuse decided to hand out is gone. Re-check after
    publishing and fall through to discovery instead.
  reclaim installs an entry a reaper reserved after its check -- the warm
    entry is still visible during the stop, and the reaper's claim succeeds
    because reclaim's own take() just made the lease ours. Re-check likewise.
  the reservation was released before the entry was removed -- the pop
    belonged to the caller, leaving a gap where the container is stopped, the
    entry is still in `_warm_pool`, and nothing marks it.
    `_destroy_warm_entry` removes it itself, inside the reservation; the pop
    stays deferred relative to the stop, just not to the reservation.
  reconcile adopts a container this instance is tearing down -- adoption is a
    promote and needs the same reservation check as the others. Neither
    existing guard excludes it: the claim succeeds because the lease is ours,
    and on `memory` the recovery grace is skipped outright.

The pre-round-trip checks in reuse and reclaim are kept as early-outs, since
they skip a health check and a store round trip on a doomed entry, and are
pinned to that job rather than to a correctness role they no longer hold.

The teardown reservation predicate runs under `_lock`, so it must not touch
the lock. Documented rather than engineered around: making the lock reentrant
to tolerate it would trade a loud hang for a quiet class of re-entrancy bugs
across the rest of the provider.

* fix(sandbox): honor local teardown after ownership publish

* fix(sandbox): clear a stale warm entry when an id becomes active

Active and warm are exclusive states, and the two register paths were the
only place that could hold both: they inserted into `_sandboxes` without
popping `_warm_pool`, so one container ended up with two reapers.
`_reap_expired_warm` judges an entry by its warm timestamp and never
consults `_last_activity`, so it stops a container an agent is actively
using while `_sandboxes` still hands out its client.

Reachable because `_reconcile_orphans` adopts an untracked-but-running
container into the warm pool inside the register's publish -> track
window, and on the `memory` store it adopts on sight:
`_adoptable_after_grace` short-circuits when `supports_cross_process` is
False, so an id carrying this process's own lease reads as adoptable.
That window is new to this branch -- on main the track was a single
locked insert with nothing before it.

Both register paths now pop the warm entry inside the same locked
section that installs the active one.

* fix(sandbox): harden ownership renewal teardown

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-21 09:09:40 +08:00
Daoyuan Li
283cea567e
fix(scripts): broaden support bundle secret-key redaction denylist (#4242)
* fix(scripts): broaden support bundle secret-key redaction denylist

SECRET_KEY_RE only matched a fixed keyword allowlist, so a secret stored
under an unanticipated key name inside an open-ended config dict (e.g.
guardrails.provider.config, an arbitrary provider-kwargs dict) was emitted
verbatim into config-summary.json even though manifest.json claims
redacted_secret_fields=true. This gap was flagged on PR #3886's review
before merge but not fully addressed.

Broaden the key-name match to mirror env_policy.py's wildcard denylist
(*KEY*/*SECRET*/*TOKEN*/*PASS*/*CREDENTIAL*/*DSN*) already used for sandbox
env-scrubbing, plus its no-flag credential exact names (GH_PAT/GITHUB_PAT/
REDIS_AUTH/REDISCLI_AUTH/PGSERVICEFILE). The new bare key/pass/dsn
alternatives are boundary-guarded so they match only their own delimited
token, not an unrelated word that starts with the same letters (routing
"keywords", guardrails "passport").

* fix(scripts): stop the pass token boundary from missing passphrase/passcode

SECRET_KEY_RE's bare "pass" alternative, (?<![a-zA-Z])pass(?![a-zA-Z]),
excludes any key where "pass" is followed by another letter. That
correctly keeps "passport" out of the redaction set, but it also
excludes genuine secret-bearing key names like "passphrase" and
"passcode" -- both of which env_policy.py's *PASS* substring denylist
does catch, so a secret stored under either name in an open-ended
config dict (e.g. guardrails.provider.config) would still leak into
config-summary.json.

Narrow the lookahead to only exclude a trailing "port" -- pass(?!port)
-- so passphrase/passcode/pass/db_pass all match while passport stays
excluded; compass/bypass stay excluded via the existing leading-letter
lookbehind, independent of the lookahead.

Added a regression test covering both the newly-caught names and the
still-excluded ones in one place. Reverting to the old lookahead
reproduces the exact leak (passphrase left unredacted); with the fix,
tests/test_support_bundle.py (30 tests) is green, and ruff check/format
are clean.
2026-07-19 07:39:52 +08:00
Zheng Feng
d57f695769
fix(helm): default sandbox Services to ClusterIP (#3929) (#4190)
* fix(helm): default sandbox Services to ClusterIP (#3929)

The K8s sandbox provisioner supports both NodePort and ClusterIP via
SANDBOX_SERVICE_TYPE (added in #4016), but the Helm chart never set it,
so real-cluster installs inherited the NodePort default. That bound the
code-execution sandbox on every node's interfaces - including externally
reachable ones on GKE/EKS/AKS - and pinned every sandbox URL to one node
IP (SPOF on node reboot/drain/ephemeral-IP).

Default the chart to ClusterIP: the provisioner returns a cluster-DNS URL
(http://sandbox-<id>-svc.<ns>.svc.cluster.local:8080) so the gateway->
sandbox hop stays inside the cluster network - no node IP, no 30xxx port,
no external exposure. The chart always runs the gateway in-cluster, so
ClusterIP is always correct there.

NodePort remains an opt-in (provisioner.sandboxServiceType: NodePort +
nodeHost) for the Docker-Compose/hybrid path where the gateway is not in
K8s and cannot resolve .svc.cluster.local; the provisioner code default
stays NodePort for that path.

- values.yaml: add provisioner.sandboxServiceType ("ClusterIP")
- provisioner-deployment.yaml: emit SANDBOX_SERVICE_TYPE; gate the
  NODE_HOST block on NodePort mode (default "ClusterIP" for upgrade safety)
- NOTES.txt + README.md: document ClusterIP default + NodePort opt-in

No change to docker/provisioner/app.py (already mode-aware since #4016)
or RBAC (services verbs already cover ClusterIP).

* test(helm): assert sandbox Service-type gating + CHANGELOG the default flip (#3929)

Address review on #4190:

- Add scripts/check_chart_sandbox_service.sh: renders the chart for the
  default (ClusterIP, no NODE_HOST), the NodePort opt-in (both emitted),
  and NodePort+nodeHost (literal value, not downward API). Locks in the
  #3929 gating so a regression (e.g. re-adding an unconditional NODE_HOST,
  or dropping the `default "ClusterIP"` upgrade-safety fallback) fails CI.
  Wired into .github/workflows/chart.yaml validate-chart job. (#2)
- CHANGELOG [Unreleased] -> Changed: note the NodePort->ClusterIP default
  flip on upgrade + the `sandboxServiceType: NodePort` opt-back-in. (#4)

No chart template changes (the gating itself landed in the first commit).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-16 14:30:05 +08:00
Daoyuan Li
656f6b364c
fix(skills): recognize fully deleted public skill packages in review CI (#4169)
select_skill_packages() resolved every changed non-SKILL.md path to its
owning package via an unconditional depth-3 fallback, then queued that
path for review. When a PR deletes an entire public skill package (not
just SKILL.md, but its scripts/assets/etc. too), the fallback still
returned the package directory even though it no longer exists on disk
post-deletion. The review CLI then reported a false
structure.missing-skill-md blocker for a path that isn't there,
failing CI on a routine, correct package removal.

Skip a resolved package only when every changed file under it was a
deletion and the package directory itself is gone from disk - i.e. the
whole package was intentionally removed. A package left in a
broken/partial state (e.g. SKILL.md deleted while sibling files
remain) still resolves to an existing directory, so it is unaffected
and continues to be queued and flagged.
2026-07-14 23:08:33 +08:00
pclin
fabadae416
add Volcengine Coding Plan to quikly setup (#4141)
* add Volcengine Coding Plan to quikly setup

* modify model list
2026-07-14 13:01:58 +08:00
Zheng Feng
7d1a8fb753
ci: add nightly build for images + helm chart (#4050)
* ci: add nightly build workflow for images + helm chart

Nightly build of backend/frontend/provisioner images and the helm chart,
pushed to GHCR with nightly + nightly-YYYYMMDD tags (latest stays on v*
releases). amd64 only. Gated to the upstream repo (bytedance/deer-flow).

Documented in RELEASING.md.

* ci(nightly): harden chart patch + dedupe config_version check

Address PR #4050 review feedback:

- Gate the in-workflow chart patches with grep assertions so a drifted
  Chart.yaml/values.yaml fails loudly instead of silently shipping a chart
  that pulls the release `latest` images (sed exits 0 on zero matches).
- Suffix the nightly chart version with the short SHA
  (<base>-nightly.<date>-<sha>) so a same-day re-dispatch re-publishes
  cleanly; OCI chart versions are immutable and otherwise collide.
- Note in the image-tags comment that :nightly-<date> is mutable within a
  day and :sha-<short> is the only truly immutable pin.
- Extract the config_version drift check into scripts/check_config_version.sh,
  shared by chart.yaml and nightly.yaml, so the parsing logic lives in one
  place.
2026-07-11 18:29:48 +08:00
Ryker_Feng
41658c5ff4
feat(skills): add skill review quality gate (#4037)
* feat(skills): add skill review quality gate

* fix(skills): skip review eval fixtures in CI

* fix(skills): ignore review eval fixtures in bundled scans

* fix(skill-review): harden review gate boundaries

* fix(skills): address skill review gate feedback
2026-07-11 15:58:07 +08:00
Zheng Feng
bc9ee9645c
feat(deploy): first-class Helm chart for Kubernetes deployment (#3987)
* feat(helm): add production-ready Helm chart for Kubernetes deployment

Adds deploy/helm/deer-flow, a native-Kubernetes translation of the
production docker-compose stack, plus CI to publish its images and chart.

* ci(release): gate releases on version-source consistency

Add a reusable verify-versions workflow invoked by both chart.yaml and
container.yaml on v* tags. It runs scripts/verify_versions.sh against the
tag and fails the release — skipping all image and chart publishing — when
Chart.yaml (version + appVersion), backend/pyproject.toml, or
frontend/package.json don't all match the tag.

Add scripts/verify_versions.sh (the check, also runnable locally) and
scripts/bump_version.sh (bumps all four sources in lockstep, then
self-verifies). Document the release flow in RELEASING.md and link it from
AGENTS.md.

* fix(deploy): address Helm chart review feedback (#3987)

Three review items from willem-bd:

1. nginx IPv6 listen strip never matched. The sed pattern required a `;`
   immediately after `2026`, but the rendered config emits
   `listen [::]:2026 default_server;` (space + `default_server` before the
   `;`), so the line was never deleted and nginx crash-looped on pods
   without IPv6 (`socket() :::2026 failed (97: Address family not
   supported)`). Drop the trailing `;` from the pattern so it matches.
   Same latent bug fixed in docker-compose-dev.yaml.

2. Passwords were spliced into DSNs verbatim, so a password containing
   URL-special chars (@ : / # ? % [ ] space) produced a malformed DSN and
   a confusing parse error. Add a `deer-flow.urlEscape` helper
   (replace-based: Sprig lacks urlqueryescape, and regexReplaceAllLiteral
   treats the replacement as a regex template so `[`/`]`/`?` break it) and
   apply it to the password in the postgres and redis DSNs. The raw
   `postgres-password` / `redis-password` keys stay unencoded - they back
   POSTGRES_PASSWORD / REDIS_PASSWORD, not a URL segment.

3. NODE_HOST defaulted to "gateway", which can never route: the gateway
   Service is ClusterIP:8001 and knows nothing of a sandbox NodePort, so a
   user who skips the caveat gets unreachable sandboxes with no error at
   install time. Default NODE_HOST to the provisioner pod's node IP via
   the downward API (status.hostIP) - a NodePort is exposed on every node,
   so <node-IP>:<NodePort> routes from the gateway on most clusters.
   `provisioner.nodeHost` remains an override for CNIs/policies that block
   pod->node-IP traffic. Updated NOTES.txt, values.yaml, and the chart
   README. (#3929 remains the long-term fix - ClusterIP + cluster-DNS URL
   removes NODE_HOST and the NodePort exposure entirely.)

Validated with helm lint, helm template (incl. a special-char password
rendering the encoded DSNs), and a sed pattern-match check.

* fix(deploy): address round-2 Helm chart review feedback (#3987)

Three "Medium" items from willem-bd:

1. No helm lint / helm template gate before publish. A template regression
   ships as an immutable OCI artifact (GHCR won't overwrite --version), so
   gate packaging on `helm lint` + `helm template --include-crds` in
   chart.yaml before `helm package`. (ct lint / helm-unittest deferred.)

2. Action pinning inconsistent + PR body overstates it. SHA-pin
   actions/checkout (v6.0.3, df4cb1c0) and actions/attest-build-provenance
   (v2.4.0, e8998f94) across the publishing workflows (chart.yaml,
   container.yaml, verify-versions.yml), matching the existing docker/*
   SHA-pin pattern. Resolves the checkout @v4/@v6 mismatch and makes the
   "SHA-pinned actions" claim accurate. Other pre-existing workflows left
   untouched (out of scope for this PR).

3. Provisioner RBAC broader than needed. Dropped the unused update/patch
   verbs and the pods/exec + events rules from the provisioner Role -
   audited against docker/provisioner/app.py, which only calls
   get/create/delete on pods and get/list/create/delete on services. Fixed
   NOTES.txt to accurately describe the grant instead of understating it as
   "create Pods and Services". The remaining scope concern - verbs apply to
   all Pods in the namespace, not just sandbox Pods - is still deferred
   (RBAC can't scope by label; needs a dedicated namespace or admission
   control), now noted in NOTES.txt and README.

Validated with helm lint + helm template (narrowed Role renders with
exactly get/list/watch/create/delete).

* feat(helm): enable sandbox+web tools out of the box

The chart's default config loaded zero agent tools (config.tools empty ->
"Total tools loaded: 0"), so a fresh install gave an agent that could do
nothing useful. Add tool_groups + tools to the default config block:

- web: web_search (ddg), web_fetch (jina), image_search - no API key
- file:read: ls, read_file, glob, grep
- file:write: write_file, str_replace
- bash

The file/bash tools run inside the AIO sandbox the chart already
configures; the web tools need outbound internet from the gateway pod
(swap backends or drop entries for air-gapped clusters - see
config.example.yaml).

Also bump config_version 15 -> 19 to match config.example.yaml (the chart
had drifted behind). NOTES.txt and the README example updated to match.

* ci(helm): add chart validation + config_version drift check on PR

Extend the chart workflow with a PR-triggered validate-chart job that runs
helm lint, helm template --include-crds, and a config_version drift check:
it parses config_version from both config.example.yaml and the chart's
values.yaml and fails the build (with a ::error:: naming the files to bump)
if the chart is behind the example. This catches the kind of drift this
PR is fixing - the chart sat at v15 while the example moved to v19 - before
it can merge again.

verify-versions and publish-chart stay tag-only; publish-chart now
needs: [verify-versions, validate-chart]. validate-chart runs on both
PRs and tag pushes: the tag arm is required because a job that `needs`
a skipped job is itself skipped under the default success() check, so
validate-chart must actually run on tag pushes or publish-chart would
never fire.

* Bump config version to 20
2026-07-09 15:40:53 +08:00
Xinmin Zeng
857fb96269
fix(sandbox): stop setup-sandbox from pre-pulling the stale :latest sandbox image (#3983)
* fix(sandbox): stop setup-sandbox from pre-pulling the stale :latest image

scripts/setup-sandbox.sh's fallback (used whenever config.yaml has no
uncommented sandbox.image) pulled the volces mirror's :latest tag. We
confirmed in #3921/#3922 that this tag is frozen on the pre-1.9.3
all-in-one-sandbox digest (1.0.0.156), which lacks the /v1/bash/*
routes required-secrets skills need — so the one script whose entire
job is 'pre-pull a working sandbox image' was pre-pulling a known-broken
one. Pin the fallback to :1.11.0 instead.

Also update config.example.yaml's commented image: example and
'Recommended' line to the same version, so uncommenting the example
doesn't reproduce the same trap.

Out of scope on purpose: aio_sandbox_provider.py's DEFAULT_IMAGE
constant (the harness-level default for AioSandboxProvider itself)
is a separate, broader default-image decision already flagged to
maintainers in #3921 — this PR only fixes the pre-pull helper script.

Reported in #3914 (a real user deleted their stale local image, reran
make setup-sandbox, and got the same broken :latest image back).

* fix(sandbox): make setup-sandbox warn when the pull won't affect the runtime image

Self-review caught a real gap in the previous commit: AioSandboxProvider
resolves its image as `sandbox_config.image or DEFAULT_IMAGE`
(aio_sandbox_provider.py:214), and DEFAULT_IMAGE is deliberately left
untouched (still the frozen :latest, per #3921 — that's a maintainer
decision, not this PR's scope). So when config.yaml has no uncommented
sandbox.image, pre-pulling :1.11.0 alone creates a NEW inconsistency:
the script reports success pulling a modern image, but the sandbox
that actually starts still falls back to the broken :latest — silently
leaving the user's underlying required-secrets/bash.exec problem
unfixed, which is worse than the previous consistent-but-broken
behavior (pre-pull :latest, run :latest).

Make the unconfigured path loud about this instead of silent: print
the exact config.yaml snippet needed to make the pulled image actually
take effect.
2026-07-07 21:05:28 +08:00
ly-wang19
f0f9dd6656
feat(setup): ask whether OpenAI-compatible gateway models support thinking (#3428)
The "Other OpenAI-compatible" wizard provider lets users supply a custom base_url and model name but never asked whether that model supports thinking/reasoning, so the generated config.yaml always left supports_thinking at its default of false — even for reasoning models behind the gateway.

Add an explicit ask_thinking_support flag on LLMProvider (enabled for the "other" provider) plus a pure with_thinking_support() helper. When the flag is set, the LLM step prompts via ask_yes_no; confirming wires the standard OpenAI-compatible enable/disable thinking toggles, declining records supports_thinking=false. Provider definitions are copied with dataclasses.replace, never mutated. Adds unit tests for the helper and the interactive step.

Closes #3162

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:55:39 +08:00
Vanzeren
6060d95ee0
fix(wizard): update DeepSeek provider models to v4 (#3939)
Update DeepSeek references from deprecated model names to the V4 lineup:
- deepseek-reasoner → deepseek-v4-pro
- deepseek-chat → deepseek-v4-flash

Keep docs and frontend mocks aligned with the wizard provider list.
2026-07-04 21:44:22 +08:00
Janlay
72f033fbbe
feat(gateway): add redis stream bridge (#3191)
* feat: add redis stream bridge

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(gateway): address redis stream bridge review

Redis was imported eagerly through deerflow.runtime and declared as a hard dependency, which made memory-only installs load redis.asyncio at startup and left the lazy factory import ineffective. Move redis behind an optional extra, remove the public eager re-export, and keep make_stream_bridge as the only runtime import path with an actionable install hint when the extra is missing.

Because Docker deployments now default the stream bridge to Redis via DEER_FLOW_STREAM_BRIDGE_REDIS_URL, install the redis extra explicitly in Docker/dev container flows and teach the local uv-extra detector to infer redis from both stream_bridge.type and the Redis URL env var. This keeps Docker working while preserving slim non-Docker installs.

Harden the Redis bridge by batching XREAD replay, replacing brittle ResponseError string matching with a single fallback to 0-0 for malformed Last-Event-ID values, documenting connection/retention/fail-hard behavior, and adding fake plus opt-in real Redis coverage for XADD/XREAD, replay, invalid IDs, and MAXLEN trimming.

* fix(config): bump config version for stream bridge

* fix redis stream bridge terminal handling

* fix: repair uv.lock, format redis.py, and align Dockerfile extras test

The uv.lock file was missing a closing bracket for the redis extras
section, redis.py had a formatting issue caught by ruff, and the
Dockerfile extras test did not account for the hardcoded --extra redis
flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-04 09:21:19 +08:00
NekoPunch
629477fd5c
docs: fix stale docs and typos (#3913) 2026-07-03 15:16:20 +08:00
ajayr
1f74082987
feat(community): add Crawl4AI web_fetch provider (#3821)
* feat(community): add Crawl4AI web_fetch provider

Crawl4AI is a self-hosted, no-API-key web fetcher: it runs headless
Chromium and returns server-cleaned "fit" markdown directly via its
POST /md endpoint, so no client-side readability extraction is needed.
It sits alongside the existing self-hosted Browserless provider.

- deerflow.community.crawl4ai: async Crawl4AiClient + web_fetch_tool
  (reads base_url/timeout_s/token/filter from config; "Error:" string
  convention; 4096-char cap), mirroring the browserless provider
- tests: 17 unit cases (success, HTTP error, success:false, empty,
  timeout, request error, token header, truncation, config reads)
- config.example.yaml: commented web_fetch example
- doctor: register as a no-key (free) web_fetch provider
- setup wizard: add to WEB_FETCH_PROVIDERS (no API key)
- docs: README, CONTRIBUTING, CONFIGURATION, AGENTS provider lists

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(community): address Crawl4AI provider review feedback

- timeout: robust _coerce_timeout (bool / non-numeric -> default) mirroring
  jina, so 'timeout: off' no longer becomes 0.0 and times out every request
- read web_fetch config once per invocation and pass values into the client,
  so a concurrent hot-reload can't split base_url from filter
- rename config key timeout_s -> timeout to match jina/infoquest (the
  default providers); update config.example.yaml + setup wizard
- validate + normalize the markdown filter against {fit,raw,bm25,llm};
  unknown values fall back to fit with a warning instead of an opaque HTTP 400
- client: a non-JSON 200 body (reverse proxy / auth wall) now reports the
  content-type + snippet instead of a generic JSONDecodeError
- tests: 22 cases (added non-JSON-200, _coerce_timeout, _coerce_filter,
  invalid-filter fallback, read-config-once)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-02 11:22:42 +08:00
Ryker_Feng
ddb097a72f
feat(community): add Brave image search community tool (#3866)
* Add Brave image search community tool

* fix(community): length-cap Brave web_search queries

Apply _clean_query in web_search_tool so over-long queries are trimmed
to Brave's 400-char limit before the API call, matching image_search_tool
and avoiding HTTP 422 from the Brave Search API.

* fix(community): harden Brave image search SSRF guard and dimension mapping

Address PR review findings:
- Catch ValueError from urlparse so a malformed bracketed-IPv6 URL skips
  one item instead of crashing the whole image_search call
- Reject IPv6 literals embedding a non-global IPv4 (IPv4-mapped, 6to4,
  NAT64, IPv4-compatible), closing the loopback/private SSRF bypass
- Report width/height from the dict of the URL actually returned, so a
  surviving thumbnail no longer reports the dropped original's dimensions

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-02 11:00:49 +08:00
Ryker_Feng
a8f950feb6
feat(community): add Browserless web_capture screenshot tool (#3881)
* feat(community): add Browserless web_capture screenshot tool

Add a web_capture tool that renders a page via Browserless /screenshot and
presents it through the artifact system, alongside the existing Browserless
web_fetch provider.

Hardening:
- SSRF guard: reject URLs resolving to private/loopback/link-local (incl. the
  169.254.169.254 cloud-metadata endpoint)/reserved/multicast/unspecified
  addresses; opt out via allow_private_addresses for internal targets.
- Surface a warning when Browserless renders a target page that itself
  responded with a non-2xx/3xx status (X-Response-Code), so an error/anti-bot
  page is not mistaken for valid visual evidence.
- Dedupe colliding output filenames instead of silently overwriting prior
  captures.

Docs: comment out token: $BROWSERLESS_TOKEN in tool examples (an unset $VAR
fails AppConfig startup) and document allow_private_addresses.

* fix(community): format web_capture guard + document local Browserless startup

Address PR #3881 review: fix the lint-backend failure (ruff format on
browserless/tools.py) and add local Browserless startup instructions to
CONFIGURATION.md so reviewers can run the service to try web_fetch/web_capture.
2026-07-01 23:41:58 +08:00
Zhou Kai
dd05e1a76d
fix(docker): production Postgres UV extras detection (#3897)
* Fix production postgres UV extras detection

* fix(backend): validate Docker build UV extras
2026-07-01 23:40:35 +08:00
Ryker_Feng
cf02646489
feat(scripts): add redacted community support bundle generator (#3886)
* feat(scripts): add redacted community support bundle generator

Add `make support-bundle` (scripts/support_bundle.py) to help users file
high-signal, privacy-safe GitHub issues for local setup/config/runtime
problems.

The command produces:
- `*-issue-summary.md` to paste into the issue body
- `*-issue-draft.md` scaffold for AI-assisted filing (REQUIRED placeholders,
  never invents repro/expected/summary facts)
- an optional evidence zip under `.deer-flow/support-bundles/` containing a
  stable `triage.json` plus redacted environment/config/extensions/git/doctor
  evidence

Privacy: secrets are redacted across config values, URL userinfo, query
strings, CLI flags, custom headers, bearer/sk- tokens, and home paths. The
bundle never includes `.env`, raw conversation messages, or user file
contents; optional `--thread-id` adds file manifests only. `thread_id` input
is validated against path traversal.

Wire it into the Makefile, AGENTS.md, README/README_zh, CONTRIBUTING, and the
bug-report issue template. Covered by backend/tests/test_support_bundle.py.

* fix(scripts): redact MCP env values by default in support bundle

Address PR #3886 review (willem-bd, P2): the key-name allowlist let literal
secrets under non-standard env keys (e.g. SUPABASE_SERVICE_ROLE_KEY,
R2_ACCESS_KEY, hardcoded AIza… keys) leak verbatim into the bundle that users
are told is safe to share publicly.

Mask all MCP `env` values by default, keeping only `$VAR`/`${VAR}` references
visible, and broaden SECRET_KEY_RE (access_key, pwd, private_key). Add tests
for non-keyword env secrets, broadened key names, and end-to-end zip redaction.
2026-07-01 22:30:55 +08:00
ly-wang19
b32ee26454
fix: reap macOS nginx processes on stop (#3828)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
2026-06-27 23:30:54 +08:00
jp0xz
a6dd2876f0
feat(community): add GroundRoute web search + fetch engine (#3675)
* feat(groundroute): add GroundRoute community web_search + web_fetch tools

GroundRoute is a meta search layer over six engines (Serper, Brave, Exa,
Tavily, Firecrawl, Perplexity) with price-based routing and failover. This
adds a self-contained community engine module (httpx only, no new required
deps) mirroring community/brave + community/tavily:
- web_search: POST /v1/search, normalize to {title,url,snippet,source_engine}.
- web_fetch: fetch a URL via mode=page.
- unit tests covering normalization, auth, clamping, and graceful errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(groundroute): register GroundRoute search + fetch in wizard and config

Add GroundRoute to the setup wizard provider lists (SEARCH_PROVIDERS +
WEB_FETCH_PROVIDERS) and as commented web_search + web_fetch examples in
config.example.yaml, mirroring tavily/serper/brave so SEARCH_API can select it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(groundroute): apply repo ruff format (line-length 240)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(groundroute): add GroundRoute to tools docs and config reference

Adds GroundRoute as a web_search and web_fetch option in the en + zh
tools.mdx pages (new tab alongside Tavily/Brave/Exa/etc.) and documents
GROUNDROUTE_API_KEY in CONFIGURATION.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(groundroute): define empty groundroute extra for clean install

The docs install line 'uv add deerflow-harness[groundroute]' (mirroring the
tavily/exa/firecrawl pattern) referenced an undefined extra, which uv accepts
but warns about. GroundRoute needs no extra packages (httpx is a core dep), so
declare an empty 'groundroute' extra in deerflow-harness optional-dependencies
so the documented command resolves without a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(groundroute): per-tool api key + honor caller max_results (review)

Address maintainer review on PR #3675:
- _get_api_key(tool_name): web_fetch now reads the web_fetch config block's key
  instead of always web_search, so a flow that pairs GroundRoute fetch with a
  different search engine authenticates correctly. Mirrors serper/exa/firecrawl.
- web_search honors a caller-supplied max_results (sentinel default None),
  falling back to the configured value only when omitted, so the documented
  parameter is no longer silently discarded.
- warn-once is now keyed per tool. Tests cover both fixes (web_fetch key,
  agent max_results honored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-06-21 15:55:10 +08:00
Recep S
9072075311
feat: add fastCRW provider (#3585)
* feat: add fastCRW provider

* test(fastcrw): fix env isolation and cover error, no-content, and env-fallback paths

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-06-21 09:30:55 +08:00
zhernrong92
e97d93503d
fix: make local-dev (make dev) work on non-root / NFS hosts (#3590)
* fix(scripts): avoid lsof hang during make dev cleanup on NFS

`_is_deerflow_pid` and `_report_reclaimed_ports` call `lsof -p <pid>` to
enumerate a process's open files. On hosts whose working tree or home is
on a network filesystem (NFS/autofs), `lsof -p` blocks indefinitely on the
kernel stat calls, so `make dev` / `make stop` hang forever at
"Stopping all services...".

Add `-b` (avoid kernel blocking functions) and `-w` (suppress the
resulting warnings) to both calls. The network-only `lsof -nP -iTCP`
probes are unaffected and already returned quickly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(nginx): set global error_log so local-dev nginx starts as non-root

nginx.local.conf only declared `error_log` inside the `http {}` block.
nginx opens its compiled-in default error log (on Debian/Ubuntu builds,
the absolute /var/log/nginx/error.log) at startup, before it reaches the
http-block directive. When `make dev` launches nginx as a non-root user
that path is not writable, so startup fails with:

    [emerg] open() "/var/log/nginx/error.log" failed (13: Permission denied)

Declare a global (main-context) `error_log logs/nginx-error.log warn;`.
Combined with the existing `-p $REPO_ROOT`, logging resolves to the
repo-local logs/ directory and nginx starts without elevated privileges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:20:55 +08:00