fix(sandbox): stop list_dir from reporting failures as empty (#5264)

* fix(sandbox): stop list_dir from reporting failures as empty

Remote providers swallowed find/client errors as [] and 2>/dev/null
missing paths as empty stdout. ls_tool then told the agent the
directory was (empty). Raise OSError/FileNotFoundError instead so
the tool returns Error.

* fix(sandbox): list_dir raises on missing local paths and uses find -H

Empty stdout is not a missing path when find's start point is a
symlink (E2B /mnt/acp-workspace). Dereference only the start point
with find -H. LocalSandbox now raises FileNotFoundError for a
non-directory root, matching remote providers. AIO maps a missing
result.data to OSError rather than FileNotFoundError.

* fix(sandbox): group AIO list_dir find type predicates

Without parentheses, find PATH -maxdepth N -type f -o -type d applies
-type d without maxdepth and can drop files from the listing.

* fix(sandbox): distinguish list_dir command failure from missing path

Tenki, Boxlite, and OpenSandbox treated any empty find stdout as
FileNotFoundError, so a missing find binary (exit 127) or SDK error
looked like a missing directory. Raise OSError when find status is
outside (0, 1); keep FileNotFoundError for the find-ran-but-empty case.

* fix(sandbox): apply list_dir exit-status contract to AIO and E2B

Same gap as Tenki/Boxlite/OpenSandbox: empty find stdout with exit 127
was FileNotFoundError. Raise OSError when the status is outside (0, 1).

* fix(sandbox): classify list_dir by find status not head status

find | head under sh -lc reports head's exit code, so a missing find
binary (127) became FileNotFoundError. Record find's own status after
the bounded listing, treat SIGPIPE 141 as truncation success, and add
a shell-level regression test.

* test(auth): include projects permissions in /me contract pins

#5265 added projects:read/write/delete to the registered route set.
The /auth/me tests still pinned the pre-projects list, so CI failed
after merging main.

* fix(sandbox): do not treat missing list_dir marker as success

The generated script ended on `rm -f`, so process status was 0/1 even
when find's marker never landed. Both codes are in _FIND_OK, and the
parser fallback then classified an empty listing as FileNotFoundError —
the 127 misclassification this helper was meant to close.

Exit with find's status (126 if unknown). A missing marker is now
OSError unless the process status is already a non-OK failure.

* test(sandbox): emit list_dir status marker in provider fixtures

Parser now requires __DF_FIND_STATUS__ and refuses marker-less stdout.
Update AIO/Boxlite/E2B stubs and OpenSandbox/Tenki find fakes so listings
carry :0 and missing paths carry :1 with matching exit codes.

* style(sandbox): format list dir test fixture

* style(sandbox): format remote list dir helper

* docs(sandbox): keep guidance within the tested size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
wutongyuonce 2026-09-09 10:12:45 +08:00 committed by GitHub
parent fa89a12526
commit d8ed8160c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 536 additions and 52 deletions

View File

@ -1,7 +1,6 @@
import base64 import base64
import errno import errno
import logging import logging
import shlex
import threading import threading
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -11,6 +10,7 @@ from agent_sandbox import Sandbox as AioSandboxClient
from agent_sandbox.core.api_error import ApiError from agent_sandbox.core.api_error import ApiError
from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
@ -573,22 +573,23 @@ class AioSandbox(Sandbox):
Returns: Returns:
The contents of the directory. The contents of the directory.
""" """
resolved = path
with self._lock: with self._lock:
try: try:
result = self._client.shell.exec_command(command=f"find {shlex.quote(path)} -maxdepth {max_depth} -type f -o -type d 2>/dev/null | head -500", no_change_timeout=self._DEFAULT_NO_CHANGE_TIMEOUT) result = self._client.shell.exec_command(
output = result.data.output if result.data else "" command=remote_list_dir_command(resolved, max_depth),
if output: no_change_timeout=self._DEFAULT_NO_CHANGE_TIMEOUT,
# find delimits records with "\n" and nothing else, so split )
# on that alone: splitlines() would also break on \v, \f,
# \x1c-\x1e and \x85, all of which are legal inside a Linux
# filename. Do NOT strip entries either — a filename that
# legitimately ends in whitespace would be corrupted and
# never resolve again.
return [line for line in output.split("\n") if line]
return []
except Exception as e: except Exception as e:
logger.error(f"Failed to list directory in sandbox: {e}") logger.error(f"Failed to list directory in sandbox: {e}")
return [] raise OSError(f"Failed to list directory '{resolved}' in sandbox: {e}") from e
if result.data is None:
raise OSError(f"Failed to list directory '{resolved}' in sandbox: empty response")
return parse_remote_list_dir_output(
result.data.output or "",
resolved,
pipeline_exit_code=getattr(result.data, "exit_code", None),
)
def write_file(self, path: str, content: str, append: bool = False) -> None: def write_file(self, path: str, content: str, append: bool = False) -> None:
"""Write content to a file in the sandbox. """Write content to a file in the sandbox.

View File

@ -25,6 +25,7 @@ import threading
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING, TypeVar
from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
@ -290,10 +291,8 @@ class BoxliteBox(Sandbox):
def list_dir(self, path: str, max_depth: int = 2) -> list[str]: def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
resolved = self._resolve_path(path) resolved = self._resolve_path(path)
r = self._sh(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500") r = self._sh(remote_list_dir_command(resolved, max_depth))
# splitlines() already removed the terminators; do NOT strip entries — return parse_remote_list_dir_output(r.stdout, resolved, pipeline_exit_code=r.exit_code)
# a filename that legitimately ends in whitespace would be corrupted.
return [line for line in (r.stdout or "").splitlines() if line]
def glob( def glob(
self, self,

View File

@ -11,6 +11,7 @@ from e2b import FileNotFoundException
from e2b_code_interpreter import Sandbox as E2BClientSandbox from e2b_code_interpreter import Sandbox as E2BClientSandbox
from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
@ -340,17 +341,17 @@ class E2BSandbox(Sandbox):
with self._lock: with self._lock:
client = self._client client = self._client
if client is None: if client is None:
return [] raise RuntimeError("sandbox client has been closed")
try: try:
result = client.commands.run(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500") result = client.commands.run(remote_list_dir_command(resolved, max_depth))
output = getattr(result, "stdout", "") or ""
# splitlines() already removed the terminators; do NOT strip
# entries — a filename that legitimately ends in whitespace
# would be corrupted and never resolve again.
return [line for line in output.splitlines() if line]
except Exception as e: except Exception as e:
logger.error("Failed to list_dir %s in e2b sandbox: %s", resolved, e) logger.error("Failed to list_dir %s in e2b sandbox: %s", resolved, e)
return [] raise OSError(f"Failed to list_dir {resolved} in e2b sandbox: {e}") from e
return parse_remote_list_dir_output(
getattr(result, "stdout", "") or "",
resolved,
pipeline_exit_code=getattr(result, "exit_code", None),
)
def write_file(self, path: str, content: str, append: bool = False) -> None: def write_file(self, path: str, content: str, append: bool = False) -> None:
resolved = self._resolve_path(path) resolved = self._resolve_path(path)

View File

@ -12,6 +12,7 @@ from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
@ -324,10 +325,16 @@ class OpenSandboxSandbox(Sandbox):
if depth < 0: if depth < 0:
raise ValueError("max_depth must be non-negative") raise ValueError("max_depth must be non-negative")
resolved = self._resolve_path(path) resolved = self._resolve_path(path)
execution = self._run(f"find {shlex.quote(resolved)} -maxdepth {depth} \\( -type f -o -type d \\) 2>/dev/null | head -500") execution = self._run(remote_list_dir_command(resolved, depth))
# splitlines() already removed the terminators; do NOT strip entries — error = getattr(execution, "error", None)
# a filename that legitimately ends in whitespace would be corrupted. if error is not None:
return [line for line in execution_stdout(execution).splitlines() if line] detail = f"{getattr(error, 'name', type(error).__name__)}: {getattr(error, 'value', error)}"
raise OSError(f"Failed to list_dir {resolved}: {detail}")
return parse_remote_list_dir_output(
execution_stdout(execution),
resolved,
pipeline_exit_code=getattr(execution, "exit_code", None),
)
def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]:
if max_results <= 0: if max_results <= 0:

View File

@ -29,6 +29,7 @@ import threading
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, TypeVar
from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
@ -368,10 +369,13 @@ class TenkiSandbox(Sandbox):
def list_dir(self, path: str, max_depth: int = 2) -> list[str]: def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
resolved = self._resolve_path(path) resolved = self._resolve_path(path)
r = self._sh(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500") r = self._sh(remote_list_dir_command(resolved, max_depth))
# splitlines() already removed the terminators; do NOT strip entries — entries = parse_remote_list_dir_output(
# a filename that legitimately ends in whitespace would be corrupted. r.stdout_text or "",
return [self._virtual_path(line) for line in (r.stdout_text or "").splitlines() if line] resolved,
pipeline_exit_code=getattr(r, "exit_code", None),
)
return [self._virtual_path(line) for line in entries]
def glob( def glob(
self, self,

View File

@ -1,8 +1,8 @@
### Sandbox System (`packages/harness/deerflow/sandbox/`) ### Sandbox System (`packages/harness/deerflow/sandbox/`)
**Interface**: Abstract `Sandbox` exposes `execute_command(command, env=None)`, additive `execute_command_in_scope(..., scope_id=...)` / `release_command_scope(scope_id)` hooks, `read_file`, `write_file`, `list_dir`, `glob`, and `grep`. Providers without server-side shell sessions use pass-through scoped hooks, preserving third-party subclasses. `grep` accepts one text file or a directory tree. Optional `env` injects per-call variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges them into the host subprocess environment and `AioSandbox` uses a fresh `bash.exec(env=...)` session. **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)`). 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` has an `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths use async lifecycle hooks so Docker creation, discovery, cross-process locking, readiness polling, and release stay off the event loop. Providers that enforce a lead Agent's explicit skill policy across its tool surface set `supports_agent_skill_isolation=True`; bind-mount providers observe prepared thread roots, while upload providers implement `sync_agent_skills`. Host-backed providers report false whenever an enabled shell can bypass path mappings. The middleware fails closed before acquiring from an unsupported provider under an explicit policy. **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 providers derive deterministic IDs with `derive_sandbox_scope_token` (`sandbox/identity.py`; its keyword-only SHA-256/16-hex contract must not change or existing containers become orphaned), and serialize selected acquire/release transitions with `AcquireSerializer` (`sandbox/acquire_serialization.py`): a refcounted per-key `threading.Lock` table with bounded growth, a bounded dedicated executor so async waits never touch the event loop or default executor, worker-owned cancellation cleanup independent of a cancelled event-loop task resuming, and idempotent `close()` from provider `shutdown()`/`reset()`. AIO keys by `(user_id, thread_id)`; E2B by `(user_id, thread_id, skills_root)`; BoxLite/Tenki/OpenSandbox by the derived id. `thread_id=None` acquires (random UUIDs) bypass the serializer. **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. **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.
**Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox-backed tool call passes through the gate in `deerflow/authz/sandbox_authz.py` - a binary `authorize(principal, "sandbox", "execute", target="*")` check before either reusing a persisted sandbox id or calling `provider.acquire`. Rechecking reuse is required because authorization config and user roles can change while the sandbox remains cached. Sync tool invocations call `authorize_sandbox_execution`; async tool invocations await `authorize_sandbox_execution_async` exactly once. A task-local `ContextVar` scopes that single decision across the complete composed tool invocation, including `ReadBeforeWriteMiddleware`'s pre-write inspection, tool body, and post-read mark; the value is copied into `asyncio.to_thread` workers. Authorization denial is converted to the normal error `ToolMessage` at the composed middleware boundary and is explicitly excluded from the gate's generic fail-open handlers. Async config loading and provider class discovery/import are offloaded before `aauthorize()` so reused sandbox calls do not hash config files or import custom modules on the event loop; provider construction remains on the running event loop because async providers may initialize loop-affine clients. The gate lives at the single tool initialization entry point (`ensure_sandbox_initialized` / `ensure_sandbox_initialized_async` in `tools.py`), while `SandboxMiddleware.before_agent` / `abefore_agent` apply the matching sync/async check to eager acquisition. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of ordinary tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (authorization calls and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway upload/artifact sync calls `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates, returns a request lease, and skips sync on deny while preserving the primary operation. Callers release after their last sandbox operation; artifacts request normal parking, uploads do not. Tests: `tests/test_sandbox_authorization.py` and `tests/blocking_io/test_sandbox_authorization.py`. **Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox-backed tool call passes through the gate in `deerflow/authz/sandbox_authz.py` - a binary `authorize(principal, "sandbox", "execute", target="*")` check before either reusing a persisted sandbox id or calling `provider.acquire`. Rechecking reuse is required because authorization config and user roles can change while the sandbox remains cached. Sync tool invocations call `authorize_sandbox_execution`; async tool invocations await `authorize_sandbox_execution_async` exactly once. A task-local `ContextVar` scopes that single decision across the complete composed tool invocation, including `ReadBeforeWriteMiddleware`'s pre-write inspection, tool body, and post-read mark; the value is copied into `asyncio.to_thread` workers. Authorization denial is converted to the normal error `ToolMessage` at the composed middleware boundary and is explicitly excluded from the gate's generic fail-open handlers. Async config loading and provider class discovery/import are offloaded before `aauthorize()` so reused sandbox calls do not hash config files or import custom modules on the event loop; provider construction remains on the running event loop because async providers may initialize loop-affine clients. The gate lives at the single tool initialization entry point (`ensure_sandbox_initialized` / `ensure_sandbox_initialized_async` in `tools.py`), while `SandboxMiddleware.before_agent` / `abefore_agent` apply the matching sync/async check to eager acquisition. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of ordinary tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (authorization calls and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway upload/artifact sync calls `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates, returns a request lease, and skips sync on deny while preserving the primary operation. Callers release after their last sandbox operation; artifacts request normal parking, uploads do not. Tests: `tests/test_sandbox_authorization.py` and `tests/blocking_io/test_sandbox_authorization.py`.
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved. **Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.

View File

@ -14,13 +14,17 @@ def list_dir(path: str, max_depth: int = 2) -> list[str]:
Returns: Returns:
A list of absolute paths for files and directories, A list of absolute paths for files and directories,
excluding items matching IGNORE_PATTERNS. excluding items matching IGNORE_PATTERNS. An existing empty
directory returns an empty list.
Raises:
FileNotFoundError: If ``path`` does not exist or is not a directory.
""" """
result: list[str] = [] result: list[str] = []
root_path = Path(path).resolve() root_path = Path(path).resolve()
if not root_path.is_dir(): if not root_path.is_dir():
return result raise FileNotFoundError(path)
def _is_within_root(candidate: Path) -> bool: def _is_within_root(candidate: Path) -> bool:
try: try:

View File

@ -0,0 +1,87 @@
"""Remote ``list_dir`` find command and stdout contract.
Remote providers list with ``find ... | head`` under ``sh -lc``. POSIX ``sh``
does not enable ``pipefail``, so the pipeline's exit code is ``head``'s, not
``find``'s. A missing ``find`` binary (127) then looks like an empty listing
and becomes ``FileNotFoundError``.
The command below writes ``find``'s own status after the bounded listing so
callers can tell a missing path from a command failure. ``head`` closing the
pipe can kill ``find`` with SIGPIPE (141); that is a successful truncation,
not an error.
"""
from __future__ import annotations
import shlex
_STATUS_PREFIX = "__DF_FIND_STATUS__:"
_LIST_LIMIT = 500
# 0 = ok, 1 = find reported a missing start point / tree error, 141 = SIGPIPE
# from head truncating a large listing.
_FIND_OK = (0, 1, 141)
def remote_list_dir_command(path: str, max_depth: int, *, limit: int = _LIST_LIMIT) -> str:
"""Return a POSIX ``sh -lc`` script that lists ``path`` and records find status."""
quoted = shlex.quote(path)
depth = int(max_depth)
n = int(limit)
# Status file is written by the find side of the pipe, then printed AFTER
# head so a 500-line listing cannot truncate the marker. ``set +e`` undoes
# a login-profile ``set -e`` so a failing find still records $?. End with
# ``exit`` of that status (126 if the file is missing): the last command
# would otherwise be ``rm``, whose 0/1 is not find's status.
return (
f"set +e; _st=/tmp/df_find_$$; "
f"{{ find -H {quoted} -maxdepth {depth} \\( -type f -o -type d \\) 2>/dev/null; "
f'echo $? > "$_st"; }} | head -n {n}; '
f'st=$(cat "$_st" 2>/dev/null); '
f"printf '\\n%s\\n' {_STATUS_PREFIX}$st; "
f'rm -f "$_st"; exit "${{st:-126}}"'
)
def parse_remote_list_dir_output(
stdout: str | None,
resolved: str,
*,
pipeline_exit_code: int | None = None,
) -> list[str]:
"""Parse listing stdout, preferring the find-status marker over pipeline status.
Raises:
OSError: Command/client failure (missing binary, invocation error, ...).
FileNotFoundError: ``find`` ran and produced no entries (missing path).
"""
# find delimits records with "\n" only. splitlines() would also split on
# \v, \f, \x1c-\x1e and \x85, which are legal in Linux filenames. Do not
# strip entries: trailing whitespace can be part of the name.
lines = (stdout or "").split("\n")
if lines and lines[-1] == "":
lines.pop()
find_status: int | None = None
if lines and lines[-1].startswith(_STATUS_PREFIX):
raw = lines.pop()[len(_STATUS_PREFIX) :]
try:
find_status = int(raw)
except ValueError:
find_status = None
if lines and lines[-1] == "":
lines.pop()
if find_status is None:
# Do not treat a missing marker as success. The process status used to
# be ``rm``'s (0/1, both in _FIND_OK), which reclassified a lost 127
# as FileNotFoundError.
if pipeline_exit_code is not None and pipeline_exit_code not in _FIND_OK:
raise OSError(f"Failed to list_dir {resolved}: command exited with code {pipeline_exit_code}")
raise OSError(f"Failed to list_dir {resolved}: find status marker missing")
if find_status not in _FIND_OK:
raise OSError(f"Failed to list_dir {resolved}: command exited with code {find_status}")
entries = [line for line in lines if line]
if not entries:
raise FileNotFoundError(resolved)
return entries

View File

@ -175,7 +175,15 @@ class Sandbox(ABC):
max_depth: The maximum depth to traverse. Default is 2. max_depth: The maximum depth to traverse. Default is 2.
Returns: Returns:
The contents of the directory. The contents of the directory. An existing empty directory may
return an empty list. A missing path must not.
Raises:
FileNotFoundError: If ``path`` does not exist or is not a directory.
OSError: If the listing cannot be performed (command/client failure).
Both local and remote implementations must raise rather than
return ``[]`` for failure or a missing path: ``ls_tool``
renders an empty list as ``(empty)``.
""" """
pass pass

View File

@ -602,7 +602,7 @@ class TestListDirSerialization:
"""list_dir should hold the lock during execution.""" """list_dir should hold the lock during execution."""
lock_was_held = [] lock_was_held = []
original_exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/a\n/b"))) original_exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/a\n/b\n\n__DF_FIND_STATUS__:0\n", exit_code=0)))
def tracking_exec(command, **kwargs): def tracking_exec(command, **kwargs):
lock_was_held.append(sandbox._lock.locked()) lock_was_held.append(sandbox._lock.locked())
@ -614,6 +614,39 @@ class TestListDirSerialization:
assert result == ["/a", "/b"] assert result == ["/a", "/b"]
assert lock_was_held == [True], "list_dir must hold the lock during exec_command" assert lock_was_held == [True], "list_dir must hold the lock during exec_command"
def test_list_dir_raises_when_exec_fails(self, sandbox):
sandbox._client.shell.exec_command = MagicMock(side_effect=RuntimeError("sandbox down"))
with pytest.raises(OSError, match="Failed to list directory"):
sandbox.list_dir("/test")
def test_list_dir_raises_when_find_returns_no_entries(self, sandbox):
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="\n__DF_FIND_STATUS__:1\n", exit_code=1)))
with pytest.raises(FileNotFoundError):
sandbox.list_dir("/missing")
def test_list_dir_raises_oserror_when_result_data_is_none(self, sandbox):
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=None))
with pytest.raises(OSError, match="Failed to list directory"):
sandbox.list_dir("/test")
def test_list_dir_raises_oserror_when_find_exit_is_not_missing_path(self, sandbox):
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="", exit_code=127)))
with pytest.raises(OSError, match="exited with code 127"):
sandbox.list_dir("/test")
def test_list_dir_uses_find_H(self, sandbox):
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/test\n\n__DF_FIND_STATUS__:0\n", exit_code=0)))
sandbox.list_dir("/test")
command = sandbox._client.shell.exec_command.call_args.kwargs["command"]
assert "find -H " in command
assert "\\( -type f -o -type d \\)" in command
class TestNoChangeTimeout: class TestNoChangeTimeout:
"""Verify that no_change_timeout is forwarded to every exec_command call.""" """Verify that no_change_timeout is forwarded to every exec_command call."""
@ -657,7 +690,7 @@ class TestNoChangeTimeout:
def mock_exec(command, **kwargs): def mock_exec(command, **kwargs):
calls.append(kwargs) calls.append(kwargs)
return SimpleNamespace(data=SimpleNamespace(output="/a\n/b")) return SimpleNamespace(data=SimpleNamespace(output="/a\n/b\n\n__DF_FIND_STATUS__:0\n", exit_code=0))
sandbox._client.shell.exec_command = mock_exec sandbox._client.shell.exec_command = mock_exec
@ -904,6 +937,6 @@ class TestClose:
def test_list_dir_preserves_trailing_space_in_filename(sandbox): def test_list_dir_preserves_trailing_space_in_filename(sandbox):
""" "notes.txt " (trailing space) is a legal Linux filename; find prints it """ "notes.txt " (trailing space) is a legal Linux filename; find prints it
verbatim, one entry per line, so a per-line strip() corrupts the name.""" verbatim, one entry per line, so a per-line strip() corrupts the name."""
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/test/notes.txt \n/test/sub\n"))) sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/test/notes.txt \n/test/sub\n\n__DF_FIND_STATUS__:0\n", exit_code=0)))
assert sandbox.list_dir("/test") == ["/test/notes.txt ", "/test/sub"] assert sandbox.list_dir("/test") == ["/test/notes.txt ", "/test/sub"]

View File

@ -1309,7 +1309,7 @@ def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None:
# verbatim, one entry per line, so a per-line strip() corrupts the name. # verbatim, one entry per line, so a per-line strip() corrupts the name.
class _FindBox: class _FindBox:
async def exec(self, *argv, env=None, timeout=None): async def exec(self, *argv, env=None, timeout=None):
return types.SimpleNamespace(stdout="/mnt/user-data/workspace/notes.txt \n", stderr="", exit_code=0) return types.SimpleNamespace(stdout="/mnt/user-data/workspace/notes.txt \n\n__DF_FIND_STATUS__:0\n", stderr="", exit_code=0)
box = BoxliteBox("box-id", box=_FindBox(), run=_fake_run) box = BoxliteBox("box-id", box=_FindBox(), run=_fake_run)
@ -1318,3 +1318,40 @@ def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None:
found, truncated = box.glob("/mnt/user-data/workspace", "notes*") found, truncated = box.glob("/mnt/user-data/workspace", "notes*")
assert found == ["/mnt/user-data/workspace/notes.txt "] assert found == ["/mnt/user-data/workspace/notes.txt "]
assert truncated is False assert truncated is False
def test_list_dir_raises_when_find_returns_no_entries() -> None:
class _EmptyBox:
async def exec(self, *argv, env=None, timeout=None):
return types.SimpleNamespace(stdout="\n__DF_FIND_STATUS__:1\n", stderr="", exit_code=1)
box = BoxliteBox("box-id", box=_EmptyBox(), run=_fake_run)
with pytest.raises(FileNotFoundError):
box.list_dir("/mnt/user-data/workspace")
def test_list_dir_raises_oserror_when_find_exit_is_not_missing_path() -> None:
# find exit 1 is "start point absent"; 127 (no binary) must not look missing.
class _MissingBinaryBox:
async def exec(self, *argv, env=None, timeout=None):
return types.SimpleNamespace(stdout="", stderr="", exit_code=127)
box = BoxliteBox("box-id", box=_MissingBinaryBox(), run=_fake_run)
with pytest.raises(OSError, match="exited with code 127"):
box.list_dir("/mnt/user-data/workspace")
def test_list_dir_uses_find_H_to_dereference_start_point() -> None:
captured: list[tuple] = []
class _FindBox:
async def exec(self, *argv, env=None, timeout=None):
captured.append(argv)
return types.SimpleNamespace(stdout="/mnt/user-data/workspace\n\n__DF_FIND_STATUS__:0\n", stderr="", exit_code=0)
box = BoxliteBox("box-id", box=_FindBox(), run=_fake_run)
assert box.list_dir("/mnt/user-data/workspace") == ["/mnt/user-data/workspace"]
assert any(len(argv) >= 3 and "find -H " in str(argv[2]) for argv in captured)

View File

@ -5198,13 +5198,60 @@ def test_list_dir_preserves_trailing_space_in_filename():
# "notes.txt " (trailing space) is a legal Linux filename; find prints it # "notes.txt " (trailing space) is a legal Linux filename; find prints it
# verbatim, one entry per line, so a per-line strip() corrupts the name and # verbatim, one entry per line, so a per-line strip() corrupts the name and
# every follow-up file API call on the listed path misses the real file. # every follow-up file API call on the listed path misses the real file.
listing = SimpleNamespace(stdout="/home/user/notes.txt \n/home/user/sub\n", stderr="", exit_code=0) listing = SimpleNamespace(stdout="/home/user/notes.txt \n/home/user/sub\n\n__DF_FIND_STATUS__:0\n", stderr="", exit_code=0)
client = FakeClient(commands=FakeCommandsAPI([listing])) client = FakeClient(commands=FakeCommandsAPI([listing]))
sb = _make_sandbox(client) sb = _make_sandbox(client)
assert sb.list_dir("/home/user") == ["/home/user/notes.txt ", "/home/user/sub"] assert sb.list_dir("/home/user") == ["/home/user/notes.txt ", "/home/user/sub"]
def test_list_dir_raises_when_command_fails():
client = FakeClient(commands=FakeCommandsAPI([FakeCommandsAPI.GONE]))
sb = _make_sandbox(client)
with pytest.raises(OSError, match="Failed to list_dir"):
sb.list_dir("/home/user")
def test_list_dir_raises_when_client_closed():
sb = _make_sandbox(FakeClient())
sb.close()
with pytest.raises(RuntimeError, match="closed"):
sb.list_dir("/home/user")
def test_list_dir_raises_when_find_returns_no_entries():
# `find ... 2>/dev/null` on a missing path yields empty stdout; that is not
# a real empty directory (`find -type d` still prints the directory itself).
listing = SimpleNamespace(stdout="\n__DF_FIND_STATUS__:1\n", stderr="", exit_code=1)
client = FakeClient(commands=FakeCommandsAPI([listing]))
sb = _make_sandbox(client)
with pytest.raises(FileNotFoundError):
sb.list_dir("/home/user/missing")
def test_list_dir_raises_oserror_when_find_exit_is_not_missing_path():
listing = SimpleNamespace(stdout="", stderr="", exit_code=127)
client = FakeClient(commands=FakeCommandsAPI([listing]))
sb = _make_sandbox(client)
with pytest.raises(OSError, match="exited with code 127"):
sb.list_dir("/home/user")
def test_list_dir_uses_find_H_to_dereference_start_point():
# find defaults to -P, so a symlink start point (E2B /mnt/acp-workspace)
# would produce empty stdout and raise FileNotFoundError without -H.
listing = SimpleNamespace(stdout="/mnt/acp-workspace\n\n__DF_FIND_STATUS__:0\n", stderr="", exit_code=0)
commands = FakeCommandsAPI([listing])
sb = _make_sandbox(FakeClient(commands=commands))
assert sb.list_dir("/mnt/acp-workspace") == ["/mnt/acp-workspace"]
assert commands.calls and "find -H " in commands.calls[0]
def test_glob_preserves_trailing_space_in_filename(): def test_glob_preserves_trailing_space_in_filename():
listing = SimpleNamespace(stdout="/home/user/notes.txt \n", stderr="", exit_code=0) listing = SimpleNamespace(stdout="/home/user/notes.txt \n", stderr="", exit_code=0)
client = FakeClient(commands=FakeCommandsAPI([listing])) client = FakeClient(commands=FakeCommandsAPI([listing]))

View File

@ -330,6 +330,31 @@ class TestSymlinkEscapes:
assert "/mnt/data/nested/linked-dir/" in entries assert "/mnt/data/nested/linked-dir/" in entries
assert "/mnt/data/dir-link" not in entries assert "/mnt/data/dir-link" not in entries
def test_list_dir_raises_when_path_is_missing(self, tmp_path):
mount_dir = tmp_path / "mount"
mount_dir.mkdir()
sandbox = LocalSandbox(
"test",
[
PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False),
],
)
with pytest.raises(FileNotFoundError):
sandbox.list_dir("/mnt/data/missing")
def test_list_dir_empty_directory_returns_empty(self, tmp_path):
mount_dir = tmp_path / "mount"
mount_dir.mkdir()
sandbox = LocalSandbox(
"test",
[
PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False),
],
)
assert sandbox.list_dir("/mnt/data") == []
def test_write_file_blocks_symlink_into_nested_read_only_mount(self, tmp_path): def test_write_file_blocks_symlink_into_nested_read_only_mount(self, tmp_path):
repo_dir = tmp_path / "repo" repo_dir = tmp_path / "repo"
repo_dir.mkdir() repo_dir.mkdir()

View File

@ -136,20 +136,25 @@ class _FakeCommands:
return _execution(exit_code=9) return _execution(exit_code=9)
if command == "missing-complete": if command == "missing-complete":
return _execution(stderr=("stream ended",), exit_code=None) return _execution(stderr=("stream ended",), exit_code=None)
if command.startswith("find "): if command.startswith("find ") or "find -H " in command:
return self._find(command) return self._find(command)
if command.startswith(("grep ", "{ grep ")): if command.startswith(("grep ", "{ grep ")):
return self._grep(command) return self._grep(command)
return _execution() return _execution()
def _find(self, command: str) -> _Execution: def _find(self, command: str) -> _Execution:
tokens = shlex.split(command) match = re.search(r"(?:^|[\s;{])find(?:\s+-[HLP])*\s+(\S+)", command)
root = tokens[1].rstrip("/") or "/" root = (match.group(1).strip("'\"") if match else "").rstrip("/") or "/"
include_dirs = "d" in tokens include_dirs = "-type d" in command
paths = list(self._owner.file_data) paths = list(self._owner.file_data)
if include_dirs: if include_dirs:
paths.extend(self._owner.directories) paths.extend(self._owner.directories)
matches = sorted(path for path in set(paths) if path == root or path.startswith(f"{root}/")) matches = sorted(path for path in set(paths) if path == root or path.startswith(f"{root}/"))
if "__DF_FIND_STATUS__:" in command:
status = 0 if matches else 1
marker = f"__DF_FIND_STATUS__:{status}"
stdout = (*matches, "", marker) if matches else ("", marker)
return _execution(stdout=stdout, exit_code=status)
return _execution(stdout=tuple(matches)) return _execution(stdout=tuple(matches))
def _grep(self, command: str) -> _Execution: def _grep(self, command: str) -> _Execution:
@ -759,6 +764,23 @@ def test_sandbox_id_matches_shared_identity():
assert OpenSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1") assert OpenSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1")
def test_list_dir_raises_when_find_returns_no_entries() -> None:
remote = _FakeRemote("remote")
box = _box(remote)
with pytest.raises(FileNotFoundError):
box.list_dir("/mnt/user-data/missing")
def test_list_dir_raises_oserror_when_find_exit_is_not_missing_path() -> None:
# find exit 1 is "start point absent"; 127 (no binary) must not look missing.
box = _box(_FakeRemote("remote"))
box._run = lambda *args, **kwargs: _execution(exit_code=127)
with pytest.raises(OSError, match="exited with code 127"):
box.list_dir("/mnt/user-data/workspace")
def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None: def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None:
# "notes.txt " (trailing space) is a legal Linux filename; find prints it # "notes.txt " (trailing space) is a legal Linux filename; find prints it
# verbatim, one entry per line, so a per-line strip() corrupts the name. # verbatim, one entry per line, so a per-line strip() corrupts the name.

View File

@ -0,0 +1,186 @@
"""Remote list_dir command/parser contract, including a real POSIX sh pipeline."""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
import pytest
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
_POSIX_SH = pytest.mark.skipif(
os.name == "nt" or shutil.which("sh") is None or shutil.which("head") is None,
reason="POSIX sh pipeline required",
)
def test_parse_marker_127_is_command_failure_not_missing_path() -> None:
stdout = "\n__DF_FIND_STATUS__:127\n"
with pytest.raises(OSError, match="exited with code 127"):
parse_remote_list_dir_output(stdout, "/dir", pipeline_exit_code=0)
def test_parse_marker_1_empty_is_missing_path() -> None:
stdout = "\n__DF_FIND_STATUS__:1\n"
with pytest.raises(FileNotFoundError):
parse_remote_list_dir_output(stdout, "/dir", pipeline_exit_code=0)
def test_parse_marker_0_returns_listing_and_keeps_trailing_space() -> None:
stdout = "/dir/notes.txt \n/dir/sub\n\n__DF_FIND_STATUS__:0\n"
assert parse_remote_list_dir_output(stdout, "/dir", pipeline_exit_code=0) == [
"/dir/notes.txt ",
"/dir/sub",
]
def test_parse_sigpipe_truncated_listing_is_success() -> None:
lines = "\n".join(f"/dir/f{i}" for i in range(500))
stdout = f"{lines}\n\n__DF_FIND_STATUS__:141\n"
entries = parse_remote_list_dir_output(stdout, "/dir", pipeline_exit_code=0)
assert len(entries) == 500
assert entries[0] == "/dir/f0"
assert entries[-1] == "/dir/f499"
def test_parse_falls_back_to_pipeline_exit_without_marker() -> None:
with pytest.raises(OSError, match="exited with code 127"):
parse_remote_list_dir_output("", "/dir", pipeline_exit_code=127)
with pytest.raises(OSError, match="marker missing"):
parse_remote_list_dir_output("", "/dir", pipeline_exit_code=0)
with pytest.raises(OSError, match="marker missing"):
parse_remote_list_dir_output("/dir\n", "/dir", pipeline_exit_code=0)
@_POSIX_SH
def test_parse_without_marker_real_subprocess_status_is_not_always_ok() -> None:
"""``rm -f`` exits 0/1, both in _FIND_OK. A real process status with no marker must not become FileNotFoundError."""
for script in ("exit 0", "exit 1"):
proc = subprocess.run(["sh", "-c", script], capture_output=True, text=True, check=False)
with pytest.raises(OSError, match="marker missing"):
parse_remote_list_dir_output(
proc.stdout,
"/dir",
pipeline_exit_code=proc.returncode,
)
def test_command_records_find_status_after_head() -> None:
command = remote_list_dir_command("/mnt/acp-workspace", 2)
assert command.startswith("set +e; ")
assert "find -H " in command
assert "\\( -type f -o -type d \\)" in command
assert "head -n 500" in command
assert "__DF_FIND_STATUS__:" in command
assert command.index("find -H ") < command.index("head -n")
assert command.index("head -n") < command.index("__DF_FIND_STATUS__:")
assert 'exit "${st:-126}"' in command
def _run_list_dir_script(command: str, *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
# Providers invoke ``sh -lc``; tests use ``sh -c`` so an injected fake
# ``find`` on PATH is not overwritten by a login profile.
return subprocess.run(
["sh", "-c", command],
capture_output=True,
text=True,
env=env,
check=False,
)
def _env_with_bin(bin_dir: str) -> dict[str, str]:
env = os.environ.copy()
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
return env
@_POSIX_SH
def test_naive_find_head_pipeline_hides_find_127(tmp_path) -> None:
"""Reproduction: without capturing find's status, head's 0 wins."""
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
fake_find = fake_bin / "find"
fake_find.write_text("#!/bin/sh\nexit 127\n", encoding="utf-8")
fake_find.chmod(fake_find.stat().st_mode | stat.S_IEXEC)
naive = "find -H /dir -maxdepth 2 \\( -type f -o -type d \\) 2>/dev/null | head -n 500"
proc = _run_list_dir_script(naive, env=_env_with_bin(str(fake_bin)))
assert proc.returncode == 0
assert proc.stdout == ""
def _write_fake_find(tmp_path, script: str):
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
fake_find = fake_bin / "find"
fake_find.write_text(script, encoding="utf-8")
fake_find.chmod(fake_find.stat().st_mode | stat.S_IEXEC)
return fake_bin
@_POSIX_SH
def test_list_dir_command_surfaces_find_127_not_head_0(tmp_path) -> None:
fake_bin = _write_fake_find(tmp_path, "#!/bin/sh\nexit 127\n")
proc = _run_list_dir_script(
remote_list_dir_command("/dir", 2),
env=_env_with_bin(str(fake_bin)),
)
assert proc.returncode == 127
with pytest.raises(OSError, match="exited with code 127"):
parse_remote_list_dir_output(proc.stdout, "/dir", pipeline_exit_code=proc.returncode)
@_POSIX_SH
def test_list_dir_command_records_find_127_under_set_e(tmp_path) -> None:
fake_bin = _write_fake_find(tmp_path, "#!/bin/sh\nexit 127\n")
proc = _run_list_dir_script(
"set -e; " + remote_list_dir_command("/dir", 2),
env=_env_with_bin(str(fake_bin)),
)
assert proc.returncode == 127
with pytest.raises(OSError, match="exited with code 127"):
parse_remote_list_dir_output(proc.stdout, "/dir", pipeline_exit_code=proc.returncode)
@_POSIX_SH
@pytest.mark.skipif(shutil.which("find") is None, reason="system find required")
def test_list_dir_command_missing_path_is_file_not_found(tmp_path) -> None:
missing = tmp_path / "no-such-dir"
proc = _run_list_dir_script(remote_list_dir_command(str(missing), 2))
with pytest.raises(FileNotFoundError):
parse_remote_list_dir_output(proc.stdout, str(missing), pipeline_exit_code=proc.returncode)
@_POSIX_SH
@pytest.mark.skipif(shutil.which("find") is None, reason="system find required")
def test_list_dir_command_empty_dir_lists_the_start_point(tmp_path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
proc = _run_list_dir_script(remote_list_dir_command(str(empty), 2))
entries = parse_remote_list_dir_output(proc.stdout, str(empty), pipeline_exit_code=proc.returncode)
assert str(empty) in entries
@_POSIX_SH
def test_list_dir_command_head_truncation_is_not_an_error(tmp_path) -> None:
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
fake_find = fake_bin / "find"
fake_find.write_text(
'#!/bin/sh\ni=1\nwhile [ "$i" -le 5000 ]; do\n echo "/dir/f$i"\n i=$((i+1))\ndone\nexit 0\n',
encoding="utf-8",
)
fake_find.chmod(fake_find.stat().st_mode | stat.S_IEXEC)
proc = _run_list_dir_script(
remote_list_dir_command("/dir", 2),
env=_env_with_bin(str(fake_bin)),
)
entries = parse_remote_list_dir_output(proc.stdout, "/dir", pipeline_exit_code=proc.returncode)
assert len(entries) == 500
assert entries[0] == "/dir/f1"
assert entries[-1] == "/dir/f500"

View File

@ -12,6 +12,7 @@ from __future__ import annotations
import errno import errno
import logging import logging
import os import os
import re
import shlex import shlex
import sys import sys
import threading import threading
@ -154,10 +155,15 @@ class _FakeSandbox:
return _FakeResult(exit_code=1, stdout=b"5 passed, 1 error\n") return _FakeResult(exit_code=1, stdout=b"5 passed, 1 error\n")
if "BOOTSTRAP_OK" in script: # provider create-time bootstrap script if "BOOTSTRAP_OK" in script: # provider create-time bootstrap script
return _FakeResult(stdout=b"BOOTSTRAP_OK\n") return _FakeResult(stdout=b"BOOTSTRAP_OK\n")
if script.startswith("find "): if script.startswith("find ") or "find -H " in script:
root = shlex.split(script)[1] match = re.search(r"(?:^|[\s;{])find(?:\s+-[HLP])*\s+(\S+)", script)
root = match.group(1).strip("'\"") if match else ""
hits = [p for p in self.files if p == root or p.startswith(f"{root.rstrip('/')}/")] hits = [p for p in self.files if p == root or p.startswith(f"{root.rstrip('/')}/")]
return _FakeResult(stdout=("\n".join(hits) + "\n").encode() if hits else b"") listing = ("\n".join(hits) + "\n") if hits else ""
if "__DF_FIND_STATUS__:" in script:
status = 0 if hits else 1
return _FakeResult(stdout=f"{listing}\n__DF_FIND_STATUS__:{status}\n".encode(), exit_code=status)
return _FakeResult(stdout=listing.encode())
if script.startswith("grep "): if script.startswith("grep "):
# grep <flags> -e <pattern> <root> 2>/dev/null | head -N # grep <flags> -e <pattern> <root> 2>/dev/null | head -N
tokens = shlex.split(script) tokens = shlex.split(script)
@ -628,8 +634,9 @@ def test_glob_include_dirs_adds_directory_type_to_find() -> None:
def test_list_dir_forwards_max_depth() -> None: def test_list_dir_forwards_max_depth() -> None:
fake = _FakeSandbox() fake = _FakeSandbox()
box = TenkiSandbox("sb", fake) box = TenkiSandbox("sb", fake)
box.write_file("/mnt/user-data/workspace/a.txt", "x")
box.list_dir("/mnt/user-data/workspace", max_depth=4) box.list_dir("/mnt/user-data/workspace", max_depth=4)
find_scripts = [c["argv"][2] for c in fake.exec_calls if c["argv"][:2] == ("sh", "-lc") and c["argv"][2].startswith("find ")] find_scripts = [c["argv"][2] for c in fake.exec_calls if c["argv"][:2] == ("sh", "-lc") and "find " in c["argv"][2]]
assert find_scripts and "-maxdepth 4" in find_scripts[-1] assert find_scripts and "-maxdepth 4" in find_scripts[-1]
@ -1017,6 +1024,22 @@ def test_sandbox_id_matches_shared_identity():
assert TenkiSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1") assert TenkiSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1")
def test_list_dir_raises_when_find_returns_no_entries() -> None:
box = TenkiSandbox("sb", _FakeSandbox())
with pytest.raises(FileNotFoundError):
box.list_dir("/mnt/user-data/missing")
def test_list_dir_raises_oserror_when_find_exit_is_not_missing_path() -> None:
# find exit 1 is "start point absent"; 127 (no binary) must not look missing.
box = TenkiSandbox("sb", _FakeSandbox())
box._sh = lambda *args, **kwargs: _FakeResult(exit_code=127)
with pytest.raises(OSError, match="exited with code 127"):
box.list_dir("/mnt/user-data/workspace")
def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None: def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None:
# "notes.txt " (trailing space) is a legal Linux filename; find prints it # "notes.txt " (trailing space) is a legal Linux filename; find prints it
# verbatim, one entry per line, so a per-line strip() corrupts the name. # verbatim, one entry per line, so a per-line strip() corrupts the name.