mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
fix(sandbox): report truncated remote glob and grep results (#5427)
* fix(sandbox): report truncated remote glob and grep results
BoxLite, Tenki, E2B, and OpenSandbox run find/grep in the sandbox, cap
the raw output with `| head`, and then filter those lines in Python:
ignored directories such as node_modules are dropped and grep's glob
scope is applied. They reported truncated only when max_results matches
survived the filter. When the capped lines were mostly filtered out, a
search with real matches past the cap came back short or empty with
truncated=False, and glob_tool/grep_tool rendered it as "No files
matched" / "No matches found". With the default max_results=200 and
1,200 files under node_modules, glob("**/*.py") reported no matches for
a workspace that has src/app.py.
remote_search_command now lets one line past its limit through, and
parse_remote_search_output(..., limit=) returns RemoteSearchOutput(text,
truncated): the first `limit` lines and whether the extra line arrived.
Exactly `limit` lines stays a complete result. Each provider passes the
cap it already computed to both calls and returns that truncated from
glob and grep when fewer than max_results results survive filtering.
The glob and grep tools now describe an empty truncated result as
incomplete instead of reporting no matches, which also covers AIO grep's
forwarded truncated flag. Sandbox.glob/grep document truncated as "the
matches may be incomplete".
* docs(changelog): reference #5427 in the remote search truncation entry
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
6177b07c06
commit
ed986a10ef
10
CHANGELOG.md
10
CHANGELOG.md
@ -582,6 +582,15 @@ This section accumulates work toward the **2.1.0** milestone
|
||||
|
||||
### Fixed
|
||||
|
||||
- **sandbox:** Stop remote `glob` and `grep` from reporting "no matches" when
|
||||
their output was cut off. BoxLite, Tenki, E2B, and OpenSandbox cap the
|
||||
search's raw output and then filter it in Python (ignored directories such as
|
||||
`node_modules`, the pattern or `glob` scope), but they reported `truncated`
|
||||
only when `max_results` was reached. When the capped lines were all filtered
|
||||
out, a search with real matches past the cap came back empty and complete.
|
||||
The search now passes one line beyond its cap so a cut-off result is reported
|
||||
as truncated, and the `glob` and `grep` tools say an empty truncated result is
|
||||
incomplete instead of "No matches found". ([#5427])
|
||||
- **sandbox:** Stop host paths reaching the model when output joins them with
|
||||
`:`, as `$PATH` and `$PYTHONPATH` do. The matched path ran on through the
|
||||
rest of the list, so every later entry under the same root was left
|
||||
@ -2832,3 +2841,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
||||
[#5418]: https://github.com/bytedance/deer-flow/pull/5418
|
||||
[#5419]: https://github.com/bytedance/deer-flow/pull/5419
|
||||
[#5427]: https://github.com/bytedance/deer-flow/pull/5427
|
||||
|
||||
@ -397,6 +397,11 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **沙箱:** 远程 `glob` 与 `grep` 的输出被截断时,不再报告"没有匹配"。BoxLite、Tenki、E2B 与
|
||||
OpenSandbox 会先限制搜索的原始输出行数,再在 Python 中过滤(`node_modules` 等忽略目录、匹配模式或 `glob`
|
||||
范围),但只有达到 `max_results` 时才报告 `truncated`。若被截取的行全部被过滤掉,截断位置之后仍有
|
||||
真实匹配的搜索会返回空结果且显示为完整。现在搜索会多输出一行以判断是否被截断,`glob` 和 `grep`
|
||||
工具对被截断的空结果会说明结果不完整,而不是显示 "No matches found"。([#5427])
|
||||
- **沙箱:** 当输出用 `:` 连接主机路径(如 `$PATH`、`$PYTHONPATH`)时,主机路径不再暴露给模型。
|
||||
匹配的路径会一直延伸到列表末尾,导致同一根目录下之后的条目都未被遮蔽;多余的遮蔽轮次每次
|
||||
恰好补回一个条目,因此短列表掩盖了这一泄露。现在遮蔽时匹配的路径在 `:` 处结束。
|
||||
@ -2167,3 +2172,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
||||
[#5418]: https://github.com/bytedance/deer-flow/pull/5418
|
||||
[#5419]: https://github.com/bytedance/deer-flow/pull/5419
|
||||
[#5427]: https://github.com/bytedance/deer-flow/pull/5427
|
||||
|
||||
@ -311,12 +311,12 @@ class BoxliteBox(Sandbox):
|
||||
search = f"find -H {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null"
|
||||
r = self._sh(remote_search_command(search, resolved, limit=hard_limit))
|
||||
# A missing root or a failed find must not read as "no files matched" (#5376).
|
||||
output = parse_remote_search_output(r.stdout, resolved, tool="find")
|
||||
output = parse_remote_search_output(r.stdout, resolved, tool="find", limit=hard_limit)
|
||||
|
||||
matches: list[str] = []
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
for entry in output.splitlines():
|
||||
for entry in output.text.splitlines():
|
||||
# Do NOT strip: trailing whitespace can be part of the filename.
|
||||
if not entry or (entry != root and not entry.startswith(root_prefix)):
|
||||
continue
|
||||
@ -329,7 +329,7 @@ class BoxliteBox(Sandbox):
|
||||
matches.append(entry)
|
||||
if len(matches) >= max_results:
|
||||
return matches, True
|
||||
return matches, False
|
||||
return matches, output.truncated
|
||||
|
||||
def grep(
|
||||
self,
|
||||
@ -360,13 +360,13 @@ class BoxliteBox(Sandbox):
|
||||
search = "grep " + " ".join(flags) + f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null"
|
||||
r = self._sh(remote_search_command(search, resolved, limit=total_cap))
|
||||
# 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")
|
||||
output = parse_remote_search_output(r.stdout, resolved, tool="grep", limit=total_cap)
|
||||
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
matches: list[GrepMatch] = []
|
||||
truncated = False
|
||||
for raw in output.splitlines():
|
||||
truncated = output.truncated
|
||||
for raw in output.text.splitlines():
|
||||
try:
|
||||
file_path, line_no_str, line_text = raw.split(":", 2)
|
||||
except ValueError:
|
||||
|
||||
@ -420,12 +420,12 @@ class E2BSandbox(Sandbox):
|
||||
logger.error("Failed to glob in e2b sandbox: %s", e)
|
||||
raise OSError(f"Failed to glob {resolved} in e2b sandbox: {e}") from e
|
||||
# A missing root or a failed find must not read as "no files matched" (#5376).
|
||||
output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="find")
|
||||
output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="find", limit=hard_limit)
|
||||
|
||||
matches: list[str] = []
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
for entry in output.splitlines():
|
||||
for entry in output.text.splitlines():
|
||||
# Do NOT strip: trailing whitespace can be part of the filename.
|
||||
if not entry:
|
||||
continue
|
||||
@ -440,7 +440,7 @@ class E2BSandbox(Sandbox):
|
||||
matches.append(entry)
|
||||
if len(matches) >= max_results:
|
||||
return matches, True
|
||||
return matches, False
|
||||
return matches, output.truncated
|
||||
|
||||
def grep(
|
||||
self,
|
||||
@ -493,14 +493,14 @@ class E2BSandbox(Sandbox):
|
||||
logger.error("Failed to grep in e2b sandbox: %s", e)
|
||||
raise OSError(f"Failed to grep {resolved} in e2b sandbox: {e}") from e
|
||||
# A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376).
|
||||
output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="grep")
|
||||
output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="grep", limit=total_cap)
|
||||
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
|
||||
matches: list[GrepMatch] = []
|
||||
truncated = False
|
||||
for raw in output.splitlines():
|
||||
truncated = output.truncated
|
||||
for raw in output.text.splitlines():
|
||||
try:
|
||||
file_path, line_no_str, line_text = raw.split(":", 2)
|
||||
except ValueError:
|
||||
|
||||
@ -348,12 +348,12 @@ class OpenSandboxSandbox(Sandbox):
|
||||
search = f"find -H {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null"
|
||||
execution = self._run(remote_search_command(search, resolved, limit=hard_limit))
|
||||
# A missing root or a failed find must not read as "no files matched" (#5376).
|
||||
output = parse_remote_search_output(execution_stdout(execution), resolved, tool="find")
|
||||
output = parse_remote_search_output(execution_stdout(execution), resolved, tool="find", limit=hard_limit)
|
||||
|
||||
matches: list[str] = []
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
for entry in output.splitlines():
|
||||
for entry in output.text.splitlines():
|
||||
# Do NOT strip: trailing whitespace can be part of the filename.
|
||||
if not entry or (entry != root and not entry.startswith(root_prefix)) or should_ignore_path(entry):
|
||||
continue
|
||||
@ -362,7 +362,7 @@ class OpenSandboxSandbox(Sandbox):
|
||||
matches.append(entry)
|
||||
if len(matches) >= max_results:
|
||||
return matches, True
|
||||
return matches, False
|
||||
return matches, output.truncated
|
||||
|
||||
def grep(
|
||||
self,
|
||||
@ -398,13 +398,13 @@ class OpenSandboxSandbox(Sandbox):
|
||||
# (127) is not reported as "no matches" (#5376).
|
||||
search = f'{primary}; status=$?; if [ "$status" -eq 2 ]; then {fallback}; status=$?; fi; (exit "$status")'
|
||||
execution = self._run(remote_search_command(search, resolved, limit=hard_limit))
|
||||
output = parse_remote_search_output(execution_stdout(execution), resolved, tool="grep")
|
||||
output = parse_remote_search_output(execution_stdout(execution), resolved, tool="grep", limit=hard_limit)
|
||||
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
matches: list[GrepMatch] = []
|
||||
seen_positions: set[tuple[str, int]] = set()
|
||||
for raw in output.splitlines():
|
||||
for raw in output.text.splitlines():
|
||||
try:
|
||||
file_path, line_number_text, line = raw.split(":", 2)
|
||||
line_number = int(line_number_text)
|
||||
@ -425,7 +425,7 @@ class OpenSandboxSandbox(Sandbox):
|
||||
matches.append(GrepMatch(path=file_path, line_number=line_number, line=truncate_line(line)))
|
||||
if len(matches) >= max_results:
|
||||
return matches, True
|
||||
return matches, False
|
||||
return matches, output.truncated
|
||||
|
||||
def ping(self, timeout: float = 10) -> bool:
|
||||
if self.is_closed:
|
||||
|
||||
@ -394,12 +394,12 @@ class TenkiSandbox(Sandbox):
|
||||
search = f"find -H {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null"
|
||||
r = self._sh(remote_search_command(search, resolved, limit=hard_limit))
|
||||
# A missing root or a failed find must not read as "no files matched" (#5376).
|
||||
output = parse_remote_search_output(r.stdout_text, resolved, tool="find")
|
||||
output = parse_remote_search_output(r.stdout_text, resolved, tool="find", limit=hard_limit)
|
||||
|
||||
matches: list[str] = []
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
for entry in output.splitlines():
|
||||
for entry in output.text.splitlines():
|
||||
# Do NOT strip: trailing whitespace can be part of the filename.
|
||||
if not entry or (entry != root and not entry.startswith(root_prefix)):
|
||||
continue
|
||||
@ -412,7 +412,7 @@ class TenkiSandbox(Sandbox):
|
||||
matches.append(self._virtual_path(entry))
|
||||
if len(matches) >= max_results:
|
||||
return matches, True
|
||||
return matches, False
|
||||
return matches, output.truncated
|
||||
|
||||
def grep(
|
||||
self,
|
||||
@ -445,13 +445,13 @@ class TenkiSandbox(Sandbox):
|
||||
search = "grep " + " ".join(flags) + f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null"
|
||||
r = self._sh(remote_search_command(search, resolved, limit=total_cap))
|
||||
# A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376).
|
||||
output = parse_remote_search_output(r.stdout_text, resolved, tool="grep")
|
||||
output = parse_remote_search_output(r.stdout_text, resolved, tool="grep", limit=total_cap)
|
||||
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
matches: list[GrepMatch] = []
|
||||
truncated = False
|
||||
for raw in output.splitlines():
|
||||
truncated = output.truncated
|
||||
for raw in output.text.splitlines():
|
||||
try:
|
||||
file_path, line_no_str, line_text = raw.split(":", 2)
|
||||
except ValueError:
|
||||
|
||||
@ -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 `[]`. 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.
|
||||
**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 `[]`. The parser takes the command's `limit` and reports `truncated` when output passed it, which providers return after Python-side filtering; tools call an empty truncated result incomplete. 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.
|
||||
|
||||
@ -10,12 +10,17 @@ nothing and exited 0, exactly like a genuine "no matches" (#5376).
|
||||
command's own status after the bounded output, the same technique as
|
||||
:mod:`deerflow.sandbox.remote_list_dir`. The script always exits 0 so SDKs that
|
||||
raise on a non-zero exit still return the marker; the marker alone decides.
|
||||
|
||||
Callers filter the bounded lines in Python (ignored directories, glob scope),
|
||||
so returning fewer than ``max_results`` does not prove the search was complete.
|
||||
The command lets one line past ``limit`` through, and the parser reports
|
||||
whether it arrived; exactly ``limit`` lines is a complete result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import Literal
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
SearchTool = Literal["grep", "find"]
|
||||
|
||||
@ -33,13 +38,22 @@ _OK_STATUSES: dict[str, tuple[int, ...]] = {"grep": (0, 1, _SIGPIPE), "find": (0
|
||||
_READ_ERROR_STATUS: dict[str, int] = {"grep": 2, "find": 1}
|
||||
|
||||
|
||||
class RemoteSearchOutput(NamedTuple):
|
||||
"""Search output lines, and whether the search produced more than ``limit``."""
|
||||
|
||||
text: str
|
||||
truncated: bool
|
||||
|
||||
|
||||
def remote_search_command(search: str, root: str, *, limit: int) -> str:
|
||||
"""Wrap a ``grep``/``find`` command so its outcome survives ``| head``.
|
||||
|
||||
``search`` must write only results to stdout; callers keep ``2>/dev/null``.
|
||||
Pass the same ``limit`` to :func:`parse_remote_search_output`.
|
||||
"""
|
||||
quoted = shlex.quote(root)
|
||||
n = int(limit)
|
||||
# One extra line is the truncation signal; the parser drops it.
|
||||
n = int(limit) + 1
|
||||
return (
|
||||
f"set +e; if [ ! -e {quoted} ]; then printf '%s\\n' {_STATUS_PREFIX}{_MISSING_ROOT}; exit 0; fi; "
|
||||
f'_st=/tmp/df_search_$$; {{ {search}; echo $? > "$_st"; }} | head -n {n}; '
|
||||
@ -48,8 +62,11 @@ def remote_search_command(search: str, root: str, *, limit: int) -> str:
|
||||
)
|
||||
|
||||
|
||||
def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchTool) -> str:
|
||||
"""Return the search output without the status marker.
|
||||
def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchTool, limit: int) -> RemoteSearchOutput:
|
||||
"""Return at most ``limit`` output lines without the status marker.
|
||||
|
||||
``truncated`` is true when the search printed more than ``limit`` lines, so
|
||||
results filtered from ``text`` may be incomplete.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: The search root does not exist.
|
||||
@ -73,7 +90,7 @@ def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchToo
|
||||
except ValueError:
|
||||
raise OSError(f"Failed to {tool} under {root}: search status unavailable") from None
|
||||
if status in _OK_STATUSES[tool]:
|
||||
return "\n".join(lines)
|
||||
return RemoteSearchOutput("\n".join(lines[:limit]), len(lines) > limit)
|
||||
if status == _READ_ERROR_STATUS[tool]:
|
||||
raise OSError(f"Failed to {tool} under {root}: {tool} exited with code {status}, usually because some files or directories could not be read; results would be incomplete, so search a narrower path")
|
||||
raise OSError(f"Failed to {tool} under {root}: command exited with code {status}")
|
||||
|
||||
@ -200,7 +200,12 @@ class Sandbox(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
|
||||
"""Find paths that match a glob pattern under a root directory."""
|
||||
"""Find paths that match a glob pattern under a root directory.
|
||||
|
||||
Returns the matches and ``truncated``, which is true whenever the
|
||||
matches may be incomplete: ``max_results`` was reached, or the search
|
||||
stopped at an output cap before filtering.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -214,7 +219,11 @@ class Sandbox(ABC):
|
||||
case_sensitive: bool = False,
|
||||
max_results: int = 100,
|
||||
) -> tuple[list[GrepMatch], bool]:
|
||||
"""Search for matches inside a text file or files under a directory."""
|
||||
"""Search for matches inside a text file or files under a directory.
|
||||
|
||||
Returns the matches and ``truncated``, with the same meaning as in
|
||||
:meth:`glob`.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@ -604,6 +604,10 @@ def _resolve_local_read_path(path: str, thread_data: ThreadDataState | None) ->
|
||||
|
||||
def _format_glob_results(root_path: str, matches: list[str], truncated: bool) -> str:
|
||||
if not matches:
|
||||
# A remote search can hit its output cap before any path survives the
|
||||
# Python-side filters; that is not evidence that nothing matches.
|
||||
if truncated:
|
||||
return f"Search under {root_path} stopped at its result limit with no files matched in the part it covered; results are incomplete. Narrow the path or pattern."
|
||||
return f"No files matched under {root_path}"
|
||||
|
||||
lines = [f"Found {len(matches)} paths under {root_path}"]
|
||||
@ -617,6 +621,8 @@ def _format_glob_results(root_path: str, matches: list[str], truncated: bool) ->
|
||||
|
||||
def _format_grep_results(root_path: str, matches: list[GrepMatch], truncated: bool) -> str:
|
||||
if not matches:
|
||||
if truncated:
|
||||
return f"Search under {root_path} stopped at its result limit with no matches in the part it covered; results are incomplete. Narrow the path or add a glob filter."
|
||||
return f"No matches found under {root_path}"
|
||||
|
||||
lines = [f"Found {len(matches)} matches under {root_path}"]
|
||||
|
||||
@ -1429,6 +1429,26 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path, monkeyp
|
||||
assert box.glob(str(tmp_path), "*.md") == ([], False)
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)])
|
||||
def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, monkeypatch, op, entries, truncated) -> None:
|
||||
# max_results=1 caps the raw stream at 51 lines, and every line falls outside
|
||||
# the glob, so nothing survives the Python-side filter. Only the cap decides
|
||||
# whether that empty result is complete; reporting it as such reads as "no
|
||||
# matches" while an in-scope file may sit past the cap.
|
||||
(tmp_path / "other").mkdir()
|
||||
for index in range(entries):
|
||||
(tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8")
|
||||
box = _rs_box(tmp_path, monkeypatch)
|
||||
|
||||
if op == "grep":
|
||||
result = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1)
|
||||
else:
|
||||
result = box.glob(str(tmp_path), "src/*.js", max_results=1)
|
||||
|
||||
assert result == ([], truncated)
|
||||
|
||||
|
||||
@_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
|
||||
|
||||
@ -5409,6 +5409,26 @@ def test_remote_search_keeps_real_matches_and_genuine_no_match(tmp_path):
|
||||
assert sb.glob(str(tmp_path), "*.md") == ([], False)
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)])
|
||||
def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, op, entries, truncated) -> None:
|
||||
# max_results=1 caps the raw stream at 51 lines, and every line falls outside
|
||||
# the glob, so nothing survives the Python-side filter. Only the cap decides
|
||||
# whether that empty result is complete; reporting it as such reads as "no
|
||||
# matches" while an in-scope file may sit past the cap.
|
||||
(tmp_path / "other").mkdir()
|
||||
for index in range(entries):
|
||||
(tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8")
|
||||
sb = _rs_sandbox(tmp_path)
|
||||
|
||||
if op == "grep":
|
||||
result = sb.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1)
|
||||
else:
|
||||
result = sb.glob(str(tmp_path), "src/*.js", max_results=1)
|
||||
|
||||
assert result == ([], truncated)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("op", ["grep", "glob"])
|
||||
def test_remote_search_raises_when_the_client_call_fails(op):
|
||||
sb = _make_sandbox(FakeClient(commands=FakeCommandsAPI([FakeCommandsAPI.GONE])))
|
||||
|
||||
@ -880,3 +880,23 @@ 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
|
||||
@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)])
|
||||
def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, monkeypatch, op, entries, truncated) -> None:
|
||||
# max_results=1 caps the raw stream at 51 lines, and every line falls outside
|
||||
# the glob, so nothing survives the Python-side filter. Only the cap decides
|
||||
# whether that empty result is complete; reporting it as such reads as "no
|
||||
# matches" while an in-scope file may sit past the cap.
|
||||
(tmp_path / "other").mkdir()
|
||||
for index in range(entries):
|
||||
(tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8")
|
||||
box = _rs_box(tmp_path, monkeypatch)
|
||||
|
||||
if op == "grep":
|
||||
result = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1)
|
||||
else:
|
||||
result = box.glob(str(tmp_path), "src/*.js", max_results=1)
|
||||
|
||||
assert result == ([], truncated)
|
||||
|
||||
@ -54,30 +54,30 @@ def _env_with_fake(tmp_path, name: str, script: str) -> dict[str, str]:
|
||||
|
||||
def test_parse_missing_root_marker_is_file_not_found() -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_remote_search_output("__DF_SEARCH_STATUS__:missing\n", "/dir", tool="grep")
|
||||
parse_remote_search_output("__DF_SEARCH_STATUS__:missing\n", "/dir", tool="grep", limit=450)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stdout", ["", "/dir/a.py:1:needle\n"])
|
||||
def test_parse_without_marker_is_a_failure_not_a_result(stdout: str) -> None:
|
||||
with pytest.raises(OSError, match="status marker missing"):
|
||||
parse_remote_search_output(stdout, "/dir", tool="grep")
|
||||
parse_remote_search_output(stdout, "/dir", tool="grep", limit=450)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tool", "status"), [("grep", 0), ("grep", 141), ("find", 0), ("find", 141)])
|
||||
def test_parse_success_keeps_output_and_trailing_space(tool: str, status: int) -> None:
|
||||
stdout = f"/dir/notes.txt \n\n__DF_SEARCH_STATUS__:{status}\n"
|
||||
assert parse_remote_search_output(stdout, "/dir", tool=tool) == "/dir/notes.txt "
|
||||
assert parse_remote_search_output(stdout, "/dir", tool=tool, limit=450) == ("/dir/notes.txt ", False)
|
||||
|
||||
|
||||
def test_parse_genuine_no_match_is_empty() -> None:
|
||||
assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:1\n", "/dir", tool="grep") == ""
|
||||
assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find") == ""
|
||||
assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:1\n", "/dir", tool="grep", limit=450) == ("", False)
|
||||
assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find", limit=850) == ("", False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tool", "status"), [("grep", 2), ("grep", 126), ("grep", 127), ("find", 1), ("find", 127)])
|
||||
def test_parse_failure_without_output_raises(tool: str, status: int) -> None:
|
||||
with pytest.raises(OSError, match=f"exited with code {status}"):
|
||||
parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool)
|
||||
parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tool", "status"), [("grep", 2), ("find", 1)])
|
||||
@ -86,7 +86,7 @@ def test_parse_error_after_partial_output_still_raises(tool: str, status: int) -
|
||||
# have no partial-result channel: it must not pass as a complete search, and
|
||||
# the error must tell the agent how to recover.
|
||||
with pytest.raises(OSError, match=f"exited with code {status}") as info:
|
||||
parse_remote_search_output(f"/dir/a.py\n\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool)
|
||||
parse_remote_search_output(f"/dir/a.py\n\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450)
|
||||
assert "could not be read" in str(info.value)
|
||||
assert "narrower path" in str(info.value)
|
||||
|
||||
@ -94,21 +94,33 @@ def test_parse_error_after_partial_output_still_raises(tool: str, status: int) -
|
||||
@pytest.mark.parametrize(("tool", "status"), [("grep", 126), ("grep", 127), ("find", 127)])
|
||||
def test_parse_other_failures_do_not_blame_unreadable_paths(tool: str, status: int) -> None:
|
||||
with pytest.raises(OSError, match=f"exited with code {status}") as info:
|
||||
parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool)
|
||||
parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450)
|
||||
assert "could not be read" not in str(info.value)
|
||||
|
||||
|
||||
def test_parse_unparseable_status_is_a_failure() -> None:
|
||||
with pytest.raises(OSError, match="status unavailable"):
|
||||
parse_remote_search_output("\n__DF_SEARCH_STATUS__:\n", "/dir", tool="find")
|
||||
parse_remote_search_output("\n__DF_SEARCH_STATUS__:\n", "/dir", tool="find", limit=850)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("lines", "truncated"), [(0, False), (3, False), (4, True)])
|
||||
def test_parse_reports_truncation_only_when_output_passes_the_limit(lines: int, truncated: bool) -> None:
|
||||
"""Callers filter these lines in Python, so fewer results than ``max_results``
|
||||
no longer proves the search was complete; only the extra line does. Exactly
|
||||
``limit`` lines is a complete result, not a truncated one."""
|
||||
body = "".join(f"/dir/f{index}.py\n" for index in range(lines))
|
||||
result = parse_remote_search_output(f"{body}\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find", limit=3)
|
||||
assert result.text == "\n".join(f"/dir/f{index}.py" for index in range(min(lines, 3)))
|
||||
assert result.truncated is truncated
|
||||
|
||||
|
||||
def test_command_checks_root_first_and_records_status_after_head() -> None:
|
||||
command = remote_search_command(_grep("/mnt/data dir"), "/mnt/data dir", limit=450)
|
||||
assert command.startswith("set +e; ")
|
||||
assert "[ ! -e '/mnt/data dir' ]" in command
|
||||
assert command.index("[ ! -e ") < command.index("grep ") < command.index("head -n 450")
|
||||
assert command.index("head -n 450") < command.rindex("__DF_SEARCH_STATUS__:")
|
||||
# One line past the limit is what lets the parser tell a full result from a cut one.
|
||||
assert command.index("[ ! -e ") < command.index("grep ") < command.index("head -n 451")
|
||||
assert command.index("head -n 451") < command.rindex("__DF_SEARCH_STATUS__:")
|
||||
assert command.endswith("exit 0")
|
||||
|
||||
|
||||
@ -130,14 +142,14 @@ def test_grep_distinguishes_match_no_match_and_missing_root(tmp_path) -> None:
|
||||
(tmp_path / "src" / "app.py").write_text("def needle():\n return 1\n", encoding="utf-8")
|
||||
root = str(tmp_path)
|
||||
|
||||
found = parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep")
|
||||
assert found == f"{tmp_path / 'src' / 'app.py'}:1:def needle():"
|
||||
none = parse_remote_search_output(_run(remote_search_command(_grep(root, "zzz_nothing"), root, limit=450)), root, tool="grep")
|
||||
assert none == ""
|
||||
found = parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep", limit=450)
|
||||
assert found == (f"{tmp_path / 'src' / 'app.py'}:1:def needle():", False)
|
||||
none = parse_remote_search_output(_run(remote_search_command(_grep(root, "zzz_nothing"), root, limit=450)), root, tool="grep", limit=450)
|
||||
assert none == ("", False)
|
||||
|
||||
missing = str(tmp_path / "missing")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_remote_search_output(_run(remote_search_command(_grep(missing), missing, limit=450)), missing, tool="grep")
|
||||
parse_remote_search_output(_run(remote_search_command(_grep(missing), missing, limit=450)), missing, tool="grep", limit=450)
|
||||
|
||||
|
||||
@_POSIX_SH
|
||||
@ -147,7 +159,7 @@ def test_missing_search_binary_is_a_failure_not_a_no_match(tmp_path, binary: str
|
||||
root = str(tmp_path)
|
||||
search, tool = (_grep(root), "grep") if binary == "grep" else (_find(root), "find")
|
||||
with pytest.raises(OSError, match="exited with code 127"):
|
||||
parse_remote_search_output(_run(remote_search_command(search, root, limit=450), env=env), root, tool=tool)
|
||||
parse_remote_search_output(_run(remote_search_command(search, root, limit=450), env=env), root, tool=tool, limit=450)
|
||||
|
||||
|
||||
@_POSIX_SH
|
||||
@ -158,12 +170,12 @@ def test_find_follows_a_symlinked_root_and_reports_an_empty_tree(tmp_path) -> No
|
||||
(real / "sub" / "a.txt").write_text("x", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
out = parse_remote_search_output(_run(remote_search_command(_find(str(link)), str(link), limit=850)), str(link), tool="find")
|
||||
assert out == f"{link}/sub/a.txt"
|
||||
out = parse_remote_search_output(_run(remote_search_command(_find(str(link)), str(link), limit=850)), str(link), tool="find", limit=850)
|
||||
assert out == (f"{link}/sub/a.txt", False)
|
||||
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
assert parse_remote_search_output(_run(remote_search_command(_find(str(empty)), str(empty), limit=850)), str(empty), tool="find") == ""
|
||||
assert parse_remote_search_output(_run(remote_search_command(_find(str(empty)), str(empty), limit=850)), str(empty), tool="find", limit=850) == ("", False)
|
||||
|
||||
|
||||
@_POSIX_SH
|
||||
@ -176,7 +188,7 @@ def test_unreadable_root_is_a_failure_not_a_no_match(tmp_path) -> None:
|
||||
locked.chmod(0)
|
||||
try:
|
||||
with pytest.raises(OSError, match="exited with code 2"):
|
||||
parse_remote_search_output(_run(remote_search_command(_grep(str(locked)), str(locked), limit=450)), str(locked), tool="grep")
|
||||
parse_remote_search_output(_run(remote_search_command(_grep(str(locked)), str(locked), limit=450)), str(locked), tool="grep", limit=450)
|
||||
finally:
|
||||
locked.chmod(0o700)
|
||||
|
||||
@ -196,17 +208,31 @@ def test_unreadable_subtree_is_a_failure_not_a_partial_result(tmp_path) -> None:
|
||||
try:
|
||||
# Both searches print the readable match before failing on the locked subtree.
|
||||
with pytest.raises(OSError, match="exited with code 2.*could not be read"):
|
||||
parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep")
|
||||
parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep", limit=450)
|
||||
with pytest.raises(OSError, match="exited with code 1.*could not be read"):
|
||||
parse_remote_search_output(_run(remote_search_command(_find(root), root, limit=850)), root, tool="find")
|
||||
parse_remote_search_output(_run(remote_search_command(_find(root), root, limit=850)), root, tool="find", limit=850)
|
||||
finally:
|
||||
locked.chmod(0o700)
|
||||
|
||||
|
||||
def _counting_grep(count: int) -> str:
|
||||
return f'#!/bin/sh\ni=1\nwhile [ "$i" -le {count} ]; do\n echo "/dir/f$i:1:needle"\n i=$((i+1))\ndone\nexit 0\n'
|
||||
|
||||
|
||||
@_POSIX_SH
|
||||
def test_head_truncation_is_not_a_failure(tmp_path) -> None:
|
||||
env = _env_with_fake(tmp_path, "grep", '#!/bin/sh\ni=1\nwhile [ "$i" -le 5000 ]; do\n echo "/dir/f$i:1:needle"\n i=$((i+1))\ndone\nexit 0\n')
|
||||
out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep")
|
||||
lines = out.split("\n")
|
||||
env = _env_with_fake(tmp_path, "grep", _counting_grep(5000))
|
||||
out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep", limit=450)
|
||||
lines = out.text.split("\n")
|
||||
assert len(lines) == 450
|
||||
assert lines[0] == "/dir/f1:1:needle"
|
||||
assert out.truncated is True
|
||||
|
||||
|
||||
@_POSIX_SH
|
||||
@pytest.mark.parametrize(("count", "truncated"), [(450, False), (451, True)])
|
||||
def test_real_pipeline_reports_truncation_at_the_limit_boundary(tmp_path, count: int, truncated: bool) -> None:
|
||||
env = _env_with_fake(tmp_path, "grep", _counting_grep(count))
|
||||
out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep", limit=450)
|
||||
assert len(out.text.split("\n")) == 450
|
||||
assert out.truncated is truncated
|
||||
|
||||
@ -2,6 +2,7 @@ import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from support.symlinks import symlink_or_skip
|
||||
|
||||
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
||||
@ -123,6 +124,40 @@ def test_grep_tool_accepts_single_file_path(tmp_path, monkeypatch) -> None:
|
||||
assert str(uploads) not in result
|
||||
|
||||
|
||||
def _remote_search_runtime():
|
||||
return SimpleNamespace(state={"sandbox": {"sandbox_id": "remote-1"}}, context={"thread_id": "thread-1"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool", "arguments"),
|
||||
[
|
||||
(glob_tool, {"pattern": "src/*.py"}),
|
||||
(grep_tool, {"pattern": "needle", "glob": "src/*.py"}),
|
||||
],
|
||||
)
|
||||
def test_search_tools_do_not_report_a_truncated_empty_result_as_no_matches(monkeypatch, tool, arguments) -> None:
|
||||
"""A remote search whose output hit its cap before any line survived the glob
|
||||
filter has no results to show, but it has not proven there are none."""
|
||||
sandbox = SimpleNamespace(glob=lambda *args, **kwargs: ([], True), grep=lambda *args, **kwargs: ([], True))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
||||
|
||||
result = tool.func(runtime=_remote_search_runtime(), description="scoped search", path="/mnt/user-data/workspace", **arguments)
|
||||
|
||||
assert not result.startswith(("No files matched", "No matches found"))
|
||||
assert "incomplete" in result
|
||||
assert "/mnt/user-data/workspace" in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tool", "arguments", "expected"), [(glob_tool, {"pattern": "*.py"}, "No files matched under"), (grep_tool, {"pattern": "needle"}, "No matches found under")])
|
||||
def test_search_tools_keep_the_no_match_message_for_a_complete_empty_result(monkeypatch, tool, arguments, expected) -> None:
|
||||
sandbox = SimpleNamespace(glob=lambda *args, **kwargs: ([], False), grep=lambda *args, **kwargs: ([], False))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
||||
|
||||
result = tool.func(runtime=_remote_search_runtime(), description="search", path="/mnt/user-data/workspace", **arguments)
|
||||
|
||||
assert result == f"{expected} /mnt/user-data/workspace"
|
||||
|
||||
|
||||
def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None:
|
||||
runtime = _make_runtime(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
|
||||
@ -1138,3 +1138,23 @@ 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
|
||||
@pytest.mark.parametrize(("op", "entries", "truncated"), [("grep", 51, False), ("grep", 52, True), ("glob", 51, False), ("glob", 52, True)])
|
||||
def test_remote_search_reports_truncation_when_the_cap_hides_filtered_results(tmp_path, monkeypatch, op, entries, truncated) -> None:
|
||||
# max_results=1 caps the raw stream at 51 lines, and every line falls outside
|
||||
# the glob, so nothing survives the Python-side filter. Only the cap decides
|
||||
# whether that empty result is complete; reporting it as such reads as "no
|
||||
# matches" while an in-scope file may sit past the cap.
|
||||
(tmp_path / "other").mkdir()
|
||||
for index in range(entries):
|
||||
(tmp_path / "other" / f"f{index}.js").write_text("needle\n", encoding="utf-8")
|
||||
box = _rs_box(tmp_path, monkeypatch)
|
||||
|
||||
if op == "grep":
|
||||
result = box.grep(str(tmp_path), "needle", glob="src/*.js", max_results=1)
|
||||
else:
|
||||
result = box.glob(str(tmp_path), "src/*.js", max_results=1)
|
||||
|
||||
assert result == ([], truncated)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user