From 18b803fad9fe38cea04fb0af0bc303c2903c0550 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Mon, 14 Sep 2026 13:26:12 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 5 +++ CHANGELOG_zh.md | 4 +++ .../harness/deerflow/community/boxlite/box.py | 14 ++++++-- .../harness/deerflow/sandbox/AGENTS.md | 2 +- backend/tests/test_boxlite_provider.py | 36 +++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3d41d2f..8a612d9a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 7fcd4aeff..d849e39b1 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/backend/packages/harness/deerflow/community/boxlite/box.py b/backend/packages/harness/deerflow/community/boxlite/box.py index 531571d56..551d740c0 100644 --- a/backend/packages/harness/deerflow/community/boxlite/box.py +++ b/backend/packages/harness/deerflow/community/boxlite/box.py @@ -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 diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index 236f977fc..742cccb3d 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -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. diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py index 29fd12d95..ff8790b31 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -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)