liu584 dba3967177
fix(sandbox): wrap implicit-session probe commands in a subshell to prevent wedged shell (#1433 family) (#5546)
The AIO sandbox's implicit persistent shell session hangs forever when a
command containing a bare 'exit' is executed: exit kills the session's
shell process, and the server's response path for that exec_command
request never completes. Verified against a standalone all-in-one-sandbox
1.11.0 container via the raw SDK:

  shell.exec_command('seq 1 1000 | head -n 5')   # OK (not a SIGPIPE issue)
  shell.exec_command('echo x; exit 0')           # HANGS every time
  shell.exec_command('echo after')               # OK (server recreates shell)
  shell.exec_command('( echo x; exit 0 )')       # OK, exit code propagates

remote_list_dir_command and remote_search_command both end their probe
scripts with a bare 'exit' (to propagate the find/grep status code), so
every list_dir/grep/glob call deterministically wedges the session —
this is the root cause behind parallel [ls, bash] tool calls deadlocking
an entire run (same defect family as #1433 and #5128).

Fix: keep 'set +e' as the outermost prefix (pinned by existing tests) and
wrap the rest of each probe script in a subshell, so exit only terminates
the subshell. Output and exit codes propagate identically.

Tests: 116 passed (backend/tests/test_aio_sandbox.py, test_remote_list_dir.py,
test_remote_search.py); one endswith assertion updated to the wrapped form.

Co-authored-by: mad_max <mad_max@coscoshipping.local>
2026-09-20 19:07:48 +08:00

102 lines
4.7 KiB
Python

"""Remote ``grep`` / ``glob`` command wrapper and stdout contract.
Remote providers search with ``grep ... | head`` or ``find ... | head`` under
``sh -lc``. POSIX ``sh`` has no ``pipefail`` and the search's stderr is
discarded, so the pipeline status is ``head``'s: a missing search root, a
missing ``grep``/``find`` binary (127) or an unreadable tree all printed
nothing and exited 0, exactly like a genuine "no matches" (#5376).
:func:`remote_search_command` checks the root first and records the search
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, NamedTuple
SearchTool = Literal["grep", "find"]
_STATUS_PREFIX = "__DF_SEARCH_STATUS__:"
_MISSING_ROOT = "missing"
# head closing the pipe after the limit kills the search with SIGPIPE: a
# successful truncation, not an error.
_SIGPIPE = 141
# Statuses of a complete search. grep: 0 = matches, 1 = no match. Everything
# else fails, even after printed results.
_OK_STATUSES: dict[str, tuple[int, ...]] = {"grep": (0, 1, _SIGPIPE), "find": (0, _SIGPIPE)}
# grep 2 / find 1 usually mean an unreadable file or subdirectory. Printed
# results are then incomplete and callers have no partial-result channel, so
# the error tells the agent to narrow the search instead.
_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)
# One extra line is the truncation signal; the parser drops it.
n = int(limit) + 1
# ``set +e`` stays outermost (pinned by existing tests); the rest runs in a
# ( ... ) subshell: a bare ``exit`` in the implicit persistent session kills
# the session's shell process and the AIO server's response path hangs
# forever; a subshell ``exit`` only kills the subshell, so the session
# survives and the exit code propagates unchanged.
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}; '
f'st=$(cat "$_st" 2>/dev/null); rm -f "$_st"; '
f"printf '\\n%s\\n' {_STATUS_PREFIX}\"$st\"; exit 0 )"
)
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.
OSError: The search did not complete (missing binary, unreadable
root or subtree, invalid invocation) or its status was lost.
"""
# Split on "\n" only: splitlines() would also split on characters that are
# legal in Linux filenames. Callers keep their own per-line handling.
lines = (stdout or "").split("\n")
if lines and lines[-1] == "":
lines.pop()
if not lines or not lines[-1].startswith(_STATUS_PREFIX):
raise OSError(f"Failed to {tool} under {root}: search status marker missing")
raw = lines.pop()[len(_STATUS_PREFIX) :]
if raw == _MISSING_ROOT:
raise FileNotFoundError(root)
if lines and lines[-1] == "":
lines.pop()
try:
status = int(raw)
except ValueError:
raise OSError(f"Failed to {tool} under {root}: search status unavailable") from None
if status in _OK_STATUSES[tool]:
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}")