mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
fix(sandbox): scope BoxLite grep globs to the search root (#5419)
* fix(sandbox): scope BoxLite grep globs to the search root BoxliteBox.grep omits grep --include for busybox portability and applies the glob in Python, but it kept only the glob's last path segment and matched it against each file's basename. A scoped pattern therefore lost its directory part: grep(glob="src/*.js") returned every .js file in the tree, including vendor/ and nested src/ subdirectories that glob() with the same pattern excludes. The glob now goes through path_matches against the path relative to the search root, with the file's basename when the root is a single file -- the same scope glob() uses and the one Tenki, E2B, OpenSandbox, AIO and LocalSandbox already enforce. Like those providers, an empty glob is now passed to path_matches instead of being treated as no filter. * docs(changelog): reference #5419 in the BoxLite grep glob scope entry
This commit is contained in:
parent
daf32cd246
commit
18b803fad9
@ -582,6 +582,10 @@ This section accumulates work toward the **2.1.0** milestone
|
||||
|
||||
### Fixed
|
||||
|
||||
- **sandbox:** Stop BoxLite `grep` from ignoring the directory part of `glob`.
|
||||
It compared only file names, so `src/*.js` matched every `.js` file in the
|
||||
tree. The glob now applies to the path relative to the search root, the same
|
||||
scope as `glob()` and the other providers. ([#5419])
|
||||
- **models:** Stop every Claude model after the first from losing its
|
||||
credential when the Claude Code OAuth token is handed off through
|
||||
`CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`. Every `ClaudeChatModel` instance
|
||||
@ -2819,3 +2823,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#5401]: https://github.com/bytedance/deer-flow/pull/5401
|
||||
[#5403]: https://github.com/bytedance/deer-flow/pull/5403
|
||||
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
||||
[#5419]: https://github.com/bytedance/deer-flow/pull/5419
|
||||
|
||||
@ -397,6 +397,9 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **沙箱:** BoxLite `grep` 不再忽略 `glob` 的目录部分。此前只比较文件名,`src/*.js`
|
||||
会匹配整棵目录树中的所有 `.js` 文件。现在 glob 作用于相对搜索根目录的路径,与 `glob()`
|
||||
及其他 provider 的范围一致。([#5419])
|
||||
- **模型:** 通过 `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` 传递 Claude Code OAuth
|
||||
token 时,第一个之后的 Claude 模型不再丢失凭据。每个 `ClaudeChatModel` 实例都会重新加载凭据,
|
||||
但文件描述符只能读取一次,导致标题、摘要、subagent 模型以及之后的每次运行都没有凭据,并以
|
||||
@ -2158,3 +2161,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#5401]: https://github.com/bytedance/deer-flow/pull/5401
|
||||
[#5403]: https://github.com/bytedance/deer-flow/pull/5403
|
||||
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
||||
[#5419]: https://github.com/bytedance/deer-flow/pull/5419
|
||||
|
||||
@ -362,7 +362,8 @@ class BoxliteBox(Sandbox):
|
||||
# A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376).
|
||||
output = parse_remote_search_output(r.stdout, resolved, tool="grep")
|
||||
|
||||
include = glob.split("/")[-1] if glob else None
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
matches: list[GrepMatch] = []
|
||||
truncated = False
|
||||
for raw in output.splitlines():
|
||||
@ -376,8 +377,15 @@ class BoxliteBox(Sandbox):
|
||||
continue
|
||||
if should_ignore_path(file_path):
|
||||
continue
|
||||
if include and not path_matches(include, posixpath.basename(file_path)):
|
||||
continue
|
||||
if glob is not None:
|
||||
# Match the caller's real directory scope: a pattern like
|
||||
# "src/*.js" must not broaden to every *.js in the tree. Same
|
||||
# helper, same relative-to-root semantics as glob() above.
|
||||
if file_path != root and not file_path.startswith(root_prefix):
|
||||
continue
|
||||
rel_path = posixpath.basename(file_path) if file_path == root else file_path[len(root) :].lstrip("/")
|
||||
if not path_matches(glob, rel_path):
|
||||
continue
|
||||
matches.append(GrepMatch(path=file_path, line_number=line_number, line=truncate_line(line_text)))
|
||||
if len(matches) >= max_results:
|
||||
truncated = True
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||
|
||||
**Interface**: `Sandbox`: `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)`, `read_file`, `write_file`, `list_dir`, `glob`, `grep`. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. `grep` accepts a text file or directory tree. Per-call `env` injects secrets: `LocalSandbox` merges into the subprocess environment; `AioSandbox` uses fresh `bash.exec(env=...)` sessions. `list_dir`: missing path → `FileNotFoundError`; command/client failure → `OSError`, never `[]` (`ls_tool`: `(empty)`). Remote `glob`/`grep` share it via `sandbox/remote_search.py`: missing root → `FileNotFoundError`, failed search → `OSError`; only a genuine no-match returns `[]`. Remotes use `sandbox/remote_list_dir.py`: capture `find`'s status, not `| head`'s (`sh -lc` lacks `pipefail`); missing binary → `OSError`; truncation SIGPIPE → success.
|
||||
**Interface**: `Sandbox`: `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)`, `read_file`, `write_file`, `list_dir`, `glob`, `grep`. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. `grep` accepts a text file or directory tree. Per-call `env` injects secrets: `LocalSandbox` merges into the subprocess environment; `AioSandbox` uses fresh `bash.exec(env=...)` sessions. `list_dir`: missing path → `FileNotFoundError`; command/client failure → `OSError`, never `[]` (`ls_tool`: `(empty)`). Remote `glob`/`grep` share it via `sandbox/remote_search.py`: missing root → `FileNotFoundError`, failed search → `OSError`; only a genuine no-match returns `[]`. Remote `grep(glob=...)` scopes like `glob()` (root-relative `path_matches`), never by basename alone. Remotes use `sandbox/remote_list_dir.py`: capture `find`'s status, not `| head`'s (`sh -lc` lacks `pipefail`); missing binary → `OSError`; truncation SIGPIPE → success.
|
||||
**Provider Pattern**: `SandboxProvider` exposes `acquire`, `acquire_async`, `get`, `release`. Async agent/tool paths use async hooks to keep Docker creation, discovery, cross-process locking, readiness polling, and release off-loop. Set `supports_agent_skill_isolation=True` only when the whole tool surface enforces explicit lead Agent policy: bind mounts use prepared thread roots; upload providers implement `sync_agent_skills`. Host-backed providers report false if an enabled shell bypasses path mappings. Under explicit policy, middleware rejects unsupported providers before acquire.
|
||||
**Shared components** (RFC #4741): remote IDs use `derive_sandbox_scope_token` (`sandbox/identity.py`); preserve its keyword-only SHA-256/16-hex contract to avoid orphaning containers. `AcquireSerializer` (`sandbox/acquire_serialization.py`) serializes selected acquire/release transitions with a bounded, refcounted per-key `threading.Lock` table and dedicated bounded executor (no event-loop/default-executor blocking). Workers own cancellation cleanup without waiting for cancelled tasks to resume; provider `shutdown()`/`reset()` calls idempotent `close()`. Keys: AIO `(user_id, thread_id)`, E2B `(user_id, thread_id, skills_root)`, BoxLite/Tenki/OpenSandbox derived id. Random-UUID `thread_id=None` acquires bypass serialization.
|
||||
**Execution leases** (`sandbox/lease.py`, #5128): cross-instance ownership decides which Gateway may reap a container; process-local `SandboxLeaseManager` tracks concurrent lead, subagent, Gateway-request, and channel-upload users of one client. Runs get ephemeral owners, persisted sandboxes are retained idempotently, and the last holder performs any pending `SandboxProvider.release`. Outer lifecycle fences repeat idempotent release after their complete graph/tool/request batch drains; per-tool terminal `Command` wrappers never release because sibling handlers may still run. Fork-restored children and upload syncs use non-releasing holders: they fence the client and own scope cleanup without themselves requesting a park; an earlier normal-owner request waits for them, and a missing fork client is replaced by a normal owner. Persisted lookup plus retention is serialized per `(user_id, thread_id)`; stale bindings fall through to acquire, and a post-acquire lookup miss rolls back before raising. Provider I/O does not hold the metadata lock. Repeated cancellation cannot interrupt acquire/rollback/release reconciliation or let a `to_thread` sandbox operation outlive its enclosing execution/request holder; failures are logged without replacing the original cancellation. Lease/scope context IDs are server-owned: Gateway and worker scrub caller values; only the internal subagent path assigns a task ID. Managers are registered by provider object identity, not hash/equality, so unhashable custom providers remain valid. Subagent owners also serve as `sandbox_command_scope_id`: AIO gives each scope one ordered persistent shell session, replaces it after `ErrorObservation`, and cleans it on lease release. Registry identity is revalidated after every scope-lock wait, preventing queued commands from resurrecting released sessions. Env-bearing commands use fresh `bash.exec` sessions so secrets do not persist.
|
||||
|
||||
@ -1427,3 +1427,39 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path, monkeyp
|
||||
found, _ = box.glob(str(tmp_path), "**/*.py")
|
||||
assert [os.path.basename(path) for path in found] == ["app.py"]
|
||||
assert box.glob(str(tmp_path), "*.md") == ([], False)
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
def test_grep_glob_keeps_its_directory_prefix(tmp_path, monkeypatch) -> None:
|
||||
# grep has no portable --include, so the glob is applied in Python. Matching
|
||||
# only its basename broadened "src/*.js" to every *.js in the tree; the scope
|
||||
# must follow the same relative-to-root semantics as glob() (Tenki, E2B).
|
||||
for rel in ("src/a.js", "src/deep/b.js", "vendor/c.js"):
|
||||
(tmp_path / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / rel).write_text("const needle = 1;\n", encoding="utf-8")
|
||||
box = _rs_box(tmp_path, monkeypatch)
|
||||
|
||||
def grep_scope(glob: str) -> list[str]:
|
||||
matches, _ = box.grep(str(tmp_path), "needle", glob=glob)
|
||||
return sorted(os.path.relpath(m.path, tmp_path) for m in matches)
|
||||
|
||||
def glob_scope(glob: str) -> list[str]:
|
||||
found, _ = box.glob(str(tmp_path), glob)
|
||||
return sorted(os.path.relpath(path, tmp_path) for path in found)
|
||||
|
||||
assert grep_scope("src/*.js") == ["src/a.js"]
|
||||
for glob in ("src/*.js", "src/**/*.js", "**/*.js", "*.js"):
|
||||
assert grep_scope(glob) == glob_scope(glob), glob
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
def test_grep_single_file_path_with_matching_glob(tmp_path, monkeypatch) -> None:
|
||||
target = tmp_path / "a.txt"
|
||||
target.write_text("needle here\n", encoding="utf-8")
|
||||
box = _rs_box(tmp_path, monkeypatch)
|
||||
|
||||
matches, truncated = box.grep(str(target), "needle", glob="*.txt")
|
||||
|
||||
assert [m.path for m in matches] == [str(target)]
|
||||
assert truncated is False
|
||||
assert box.grep(str(target), "needle", glob="*.md") == ([], False)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user