Skip to content

fix(sandbox): surface local grep file read failures - #7004

Open
ranjianghong526-hash wants to merge 2 commits into
bytedance:mainfrom
ranjianghong526-hash:codex/fix-local-grep-io-errors
Open

ranjianghong526-hash wants to merge 2 commits into
bytedance:mainfrom
ranjianghong526-hash:codex/fix-local-grep-io-errors

Conversation

@ranjianghong526-hash

Copy link
Copy Markdown
Contributor

Why

Local grep can report No matches found when it could not read the requested text. is_binary_file() converts any sampling OSError into a binary-file exclusion; the surrounding search also suppresses later I/O failures. This hides permission/sharing errors and disk read failures as a successful empty or partial result.

Reproduced against unchanged main 2464566942ace4ffe71d347fde3dd9868e2ecd5d with actual Windows exclusive-share handles and non-root Linux mode-000 files. A direct open fails with PermissionError, while the existing search returns zero matches and truncated=False. Both platforms reproduce 3/3; restoring access finds the same matching text. This is an offline filesystem reproduction, not a reported production incident.

What changed

Surface area

  • Frontend UI
  • Backend API — local sandbox tool results
  • Agents / LangGraph
  • Sandbox — local file-search error reporting
  • Skills
  • Dependencies
  • Default behavior change — failed local file reads report an error instead of a successful incomplete search
  • Docs / tests / CI only

Bug fix verification

backend/tests/test_local_grep_io_errors.py: final suite on unchanged Linux main, run as a real non-root user: 11 failed, 15 passed, 2 Windows skips. Windows initial regression version: 10 failed, 11 passed, 2 POSIX skips; it preceded the extra disappearance and mid-read cases. The same production tools and real OS errors are exercised. Portable error injection additionally covers deterministic sampling/text-open/I/O paths. No assertion or production-platform spoofing is used.

Validation

  • Exact tested baseline: 2464566942ace4ffe71d347fde3dd9868e2ecd5d; Linux Python 3.12.15, uv 0.11.1, Node 24.16.0, Redis 7.4.11 and PostgreSQL 17. Dependencies synchronized with uv sync --frozen --all-packages --group dev --extra redis --extra postgres.
  • All four complete offline make test-shard SPLITS=4 GROUP=<1..4> groups ran sequentially before and after, with the original exclusions and no modified assertions/timeouts. Baseline: 32,365 passed. Patch: 32,389 passed, zero failures. The baseline has 256 raw skip records (238 unique IDs); the patch has 260 (242 unique IDs). Each includes 18 repeated module-collection skip records. The four extra unique skips are the two native Windows/non-root POSIX pairs in the root Linux suite, exercised on their proper hosts below. Existing optional/provider/platform skips are not claimed as executed.
  • JUnit audit: no missing baseline cases or overlapping executed cases; every previously passing case still passes. Exactly 28 new regression cases. Actual tested source matches all four final file hashes.
  • make test-blocking-io: 256 passed, 1 skipped, before and after.
  • Final regression with actual non-root Linux permissions: 26 passed, 2 Windows skips. Linux local/remote search suites: 439 passed, 6 platform skips.
  • Windows related search/glob/cap suites: 111 passed, 3 platform skips.
  • make lint: passed, including format check of 2,133 Python files. git diff --check: passed. Guidance check: exit 0, no errors, two existing soft-budget warnings (below hard limits).
  • During validation, main advanced to 9c74b1529b6e424b9c2e3c1b1fb6d25fdcea0e9a with an unrelated streaming-usage change. The patch applies cleanly there; the complete test counts above belong to the pinned baseline, not to that later commit's newly added tests.
  • No live model, provider deployment or browser end-to-end test is claimed. Local test results do not establish hosted CI status.

AI assistance

Tool(s) used: Codex.

How you used it: Source/related-work investigation, real OS reproduction, regression tests, minimal fix and local validation, and this draft.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

@github-actions github-actions Bot added area:docs Documentation and Markdown only area:sandbox Sandboxed execution and docker/ risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/M PR changes 100-300 lines labels Oct 17, 2026

@ShenAC-SAC ShenAC-SAC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The underlying unreadable-file problem is real: the added regressions reproduce 11 failures on the pinned base, including actual non-root POSIX permission failures, and pass on this head. However, a real Unix-domain socket inside the searched workspace exposes the regression below. The related search suites pass (368 passed, 2 Windows-only skips); native Windows and full application E2E were not run in this review.

Comment on lines +271 to +276
except OSError as exc:
# A candidate may disappear after enumeration. Other failures leave
# the search incomplete and must not become a false no-match result.
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:
continue
raise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Exclude non-regular entries before propagating read errors

os.walk includes Unix-domain sockets in files, and the candidate path only checks size before is_binary_file tries to open it. With a readable target.txt containing needle and a real bound service.sock in the same workspace, the base returns the text match, but this head makes the public local grep tool return Error: Unexpected error searching file contents: OSError: [Errno 102] Operation not supported on socket on macOS. A service socket is not unreadable text, so its presence should not prevent searching the surrounding workspace. Check the candidate stat mode and skip non-regular files before binary sampling, while retaining the new error propagation for ordinary files; add a real-socket regression alongside the permission-error cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 776000b.

Thanks for catching this. Independently reproduced three times on Linux with a real bound Unix-domain socket: the existing PR head raises ENXIO while searching the surrounding directory. The new real-socket tests also fail on that head for both a matching and a no-match query.

The fix checks stat.S_ISREG using the stat result already needed for the size limit, and skips non-regular entries before binary sampling. Ordinary file read errors still propagate. The regression uses a real socket alongside a readable text file and verifies both the public grep tool and the underlying search; a short relative bind path avoids the Unix socket pathname limit. Updated the scoped guidance accordingly.

Validation: Linux related local/remote search suites 441 passed / 6 skipped; non-root Linux I/O regressions 28 passed / 2 Windows-only skips (including real socket and mode-000 permission tests); Windows related suites 221 passed / 226 POSIX/platform skips, including actual exclusive-share failures. The three standalone socket probes pass after the fix. Full backend Ruff and formatting checks passed (2,133 files); guidance validation has zero errors and two existing soft-budget warnings. No full-backend test-suite, macOS, live-provider or browser E2E run is claimed for this follow-up.

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core fix is sound and well-tested: binary-sampling and text-read OSErrors now reach the tool handler instead of masquerading as binary exclusions or empty results, the ENOENT/ENOTDIR/ELOOP skip set keeps mid-scan disappearances best-effort, and the S_ISREG check (which also resolves the earlier socket finding) with the already-needed stat keeps syscall count flat. Three follow-ups below: the new PermissionError path reports only the requested root rather than the unreadable file (unlike the EIO path, whose sanitized message names the file), the disappearance set misses ESTALE/EISDIR races, and unreadable directories remain silently skipped so a mode-000 directory still yields a successful partial search.

# the search incomplete and must not become a false no-match result.
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:
continue
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The surfaced PermissionError names the search root, not the unreadable file. This raise routes per-file PermissionError into grep_tool's except PermissionError: return f"Error: Permission denied: {requested_path}" branch, so when a child file is unreadable the message blames the readable root and names no file. Compare the non-permission OSError branch: its _sanitize_error-ed message includes the failing file (OSError: [Errno 5] ...: '/mnt/user-data/workspace/target.txt'), so the model can exclude it via glob or read_file it directly. Here the model gets zero matches, no offending name, and every retry over the tree fails identically — one mode-000 or foreign-owned file makes grep unusable on the whole workspace with no recovery hint. Consider surfacing the failing candidate's masked path in the PermissionError result (e.g. re-raise with the virtual path, or reuse _sanitize_error in that branch).

except OSError as exc:
# A candidate may disappear after enumeration. Other failures leave
# the search incomplete and must not become a false no-match result.
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Two more races land on the raise path. The disappearance set is right for local filesystems, but: ESTALE — on NFS-mounted local workspaces a server-side delete mid-scan surfaces as a stale handle and now aborts the whole search; and EISDIR — a file replaced by a directory between stat and open (same window the ENOENT skip covers). Consider adding both; note errno.ESTALE is documented as Unix-only, so guard it (e.g. getattr(errno, "ESTALE", None)) to stay Windows-safe.

continue
except OSError as exc:
# A candidate may disappear after enumeration. Other failures leave
# the search incomplete and must not become a false no-match result.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] The no-false-no-match rationale stops at files. os.walk in candidate_files() still uses the default onerror=None, so an unreadable directory (mode-000, or a root the caller cannot list) silently truncates the walk and the tool returns a successful partial or empty result — exactly the failure mode this change removes for files. Consider an onerror handler applying the same skip/raise split, or at least documenting the asymmetry in the sandbox AGENTS.md note so the surfaced-errors claim doesn't over-promise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Documentation and Markdown only area:sandbox Sandboxed execution and docker/ risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/M PR changes 100-300 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants