TAI-YUAN GUO f5907405a0
fix(workspace): don't infer created or deleted files from truncated snapshots (#7172)
* fix(workspace): don't infer created or deleted files from truncated snapshots

A bounded workspace scan stops at max_scanned_files and marks the snapshot
truncated. Absence from such a snapshot is unknown, so creation is only
inferred from a complete before snapshot and deletion only from a complete
after snapshot. get_changed_paths() is the single filter for the summary,
text capture and output-delivery detection.

The scanner now walks subdirectories in sorted order so the truncation
boundary is reproducible across the before and after scans, and the docs
note that the budget is consumed root by root (workspace before outputs).

Supersedes #5231.

Co-authored-by: liutian <91778998+ImcLiuQian@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(runtime): record truncated delivery verification skips

Record verification_skipped=truncated_pre_run_snapshot in run.delivery
receipts when a truncated pre-run scan leaves no provable output changes.
Carry the snapshot into both normal and exceptional receipt finalization.

Keep observed-output verification and terminal statuses unchanged. Cover
success, failure, cancellation and capture failures through the real worker,
and document the per-run receipt marker.

---------

Co-authored-by: liutian <91778998+ImcLiuQian@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-22 16:52:53 +08:00

408 lines
13 KiB
Python

from __future__ import annotations
import fnmatch
import hashlib
import os
import tempfile
from codecs import BOM_UTF16_BE, BOM_UTF16_LE, getincrementaldecoder
from pathlib import Path
from deerflow.constants import BROWSER_FRAMES_DIRNAME, MCP_INTERNAL_DIRNAME, TOOL_RESULTS_DIRNAME
from .types import (
DiffUnavailableReason,
FileSnapshot,
WorkspaceChangeLimits,
WorkspaceRoot,
WorkspaceSnapshot,
)
EXCLUDED_DIR_NAMES = {
".git",
".hg",
".svn",
".cache",
# Stdio MCP subprocess temp/debug files live below this server-owned
# namespace. They remain addressable when a tool returns their path, but
# they are not user-authored workspace deliverables or workspace changes.
MCP_INTERNAL_DIRNAME,
".next",
".venv",
# Transient per-step browser screenshots: live progress feedback surfaced in
# the browser panel + inline thumbnails, not workspace deliverables. Shared
# constant with the browser tools so the name cannot drift out of sync.
BROWSER_FRAMES_DIRNAME,
# Externalized oversized tool outputs (the tool-output budget middleware's
# default storage_subdir): process feedback the model reads back via
# read_file, not workspace deliverables — same intent as the browser frames
# exclusion above. Without this, a run that externalizes any tool output
# would trip run delivery verification (produced output never presented)
# and fail as an error. Custom storage_subdir values are passed through
# ``extra_excluded_dir_names`` instead.
TOOL_RESULTS_DIRNAME,
"__pycache__",
"build",
"dist",
"node_modules",
}
BINARY_EXTENSIONS = {
".7z",
".avif",
".bmp",
".class",
".db",
".dll",
".dmg",
".doc",
".docx",
".exe",
".gif",
".gz",
".ico",
".jar",
".jpeg",
".jpg",
".mov",
".mp3",
".mp4",
".o",
".pdf",
".png",
".pyc",
".so",
".tar",
".webp",
".xls",
".xlsx",
".zip",
}
SENSITIVE_PATH_PATTERNS = (
".env",
".env.*",
"*api_key*",
"*apikey*",
"*.key",
"*.pem",
"*credential*",
"*password*",
"*private_key*",
"*secret*",
"*token*",
)
SAMPLE_BYTES = 4096
_UTF16_BOMS = (BOM_UTF16_LE, BOM_UTF16_BE)
def is_sensitive_workspace_path(path: str) -> bool:
normalized = path.lower()
parts = [part.lower() for part in Path(path).parts]
basename = parts[-1] if parts else normalized
for pattern in SENSITIVE_PATH_PATTERNS:
if fnmatch.fnmatch(basename, pattern) or fnmatch.fnmatch(normalized, pattern):
return True
if any(fnmatch.fnmatch(part, pattern) for part in parts):
return True
return False
def scan_workspace_roots(
roots: list[WorkspaceRoot],
*,
limits: WorkspaceChangeLimits | None = None,
include_text: bool = True,
text_paths: set[str] | None = None,
text_cache_dir: Path | None = None,
extra_excluded_dir_names: frozenset[str] | None = None,
) -> WorkspaceSnapshot:
resolved_limits = limits or WorkspaceChangeLimits()
cache_dir = Path(text_cache_dir) if text_cache_dir is not None else None
if cache_dir is not None:
cache_dir.mkdir(parents=True, exist_ok=True)
# Operator-customized tool_output.storage_subdir values arrive here; the
# default name is already part of EXCLUDED_DIR_NAMES, so merging is safe.
# Only single-segment directory names are meaningful: os.walk yields
# one-segment dirnames, so a nested value like "cache/tool-results" would
# never match. ToolOutputConfig enforces the single-segment contract, so a
# multi-segment value is a caller error, not a silent no-op.
excluded_dir_names = EXCLUDED_DIR_NAMES | extra_excluded_dir_names if extra_excluded_dir_names else EXCLUDED_DIR_NAMES
files: dict[str, FileSnapshot] = {}
scanned = 0
truncated = False
for root in roots:
if not root.host_path.exists():
continue
for dirpath, dirnames, filenames in os.walk(root.host_path, followlinks=False):
dirnames[:] = [dirname for dirname in sorted(dirnames) if dirname not in excluded_dir_names and not (Path(dirpath) / dirname).is_symlink()]
for filename in sorted(filenames):
if scanned >= resolved_limits.max_scanned_files:
truncated = True
return WorkspaceSnapshot(
files=files,
truncated=truncated,
text_cache_dir=str(cache_dir) if cache_dir is not None else None,
)
host_file = Path(dirpath) / filename
if host_file.is_symlink():
# A symlink must never be followed for stat/content purposes: its
# target can point anywhere on the host (including outside the
# scanned root), so it is recorded as a metadata-only stub -
# mirroring how binary/large/sensitive-looking files are handled
# below - instead of being silently omitted from the snapshot.
symlink_snapshot = _snapshot_symlink(root, host_file)
if symlink_snapshot is not None:
files[symlink_snapshot.path] = symlink_snapshot
scanned += 1
continue
if not host_file.is_file():
continue
snapshot = _snapshot_file(
root,
host_file,
limits=resolved_limits,
include_text=include_text,
text_paths=text_paths,
text_cache_dir=cache_dir,
)
if snapshot is not None:
files[snapshot.path] = snapshot
scanned += 1
return WorkspaceSnapshot(
files=files,
truncated=truncated,
text_cache_dir=str(cache_dir) if cache_dir is not None else None,
)
def _snapshot_file(
root: WorkspaceRoot,
host_file: Path,
*,
limits: WorkspaceChangeLimits,
include_text: bool,
text_paths: set[str] | None,
text_cache_dir: Path | None,
) -> FileSnapshot | None:
try:
stat = host_file.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
relative = host_file.relative_to(root.host_path).as_posix()
virtual_path = f"{root.virtual_prefix}/{relative}"
sensitive = is_sensitive_workspace_path(virtual_path)
except OSError:
return None
if sensitive:
return FileSnapshot(
path=virtual_path,
root=root.name,
size=size,
mtime_ns=mtime_ns,
sha256=None,
binary=False,
sensitive=True,
text=None,
content_unavailable_reason="sensitive",
)
try:
sample = host_file.read_bytes()[:SAMPLE_BYTES] if size <= SAMPLE_BYTES else _read_sample(host_file)
except OSError:
return None
binary = host_file.suffix.lower() in BINARY_EXTENSIONS or _looks_binary(sample)
try:
sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None
except FileNotFoundError:
return None
text: str | None = None
text_path: str | None = None
reason: DiffUnavailableReason | None = None
should_include_text = include_text and (text_paths is None or virtual_path in text_paths)
if binary:
reason = "binary"
elif size > limits.max_file_bytes_for_diff:
reason = "large"
elif not should_include_text:
text = None
else:
try:
raw = host_file.read_bytes()
except OSError:
return None
decoded = _decode_text_bytes(raw)
if decoded is None:
binary = True
reason = "binary"
elif text_cache_dir is not None:
text_path = str(_cache_text_file(decoded, virtual_path, text_cache_dir))
else:
text = decoded
return FileSnapshot(
path=virtual_path,
root=root.name,
size=size,
mtime_ns=mtime_ns,
sha256=sha256,
binary=binary,
sensitive=sensitive,
text=text,
text_path=text_path,
content_unavailable_reason=reason,
)
def _normalize_symlink_target(target: str) -> str:
"""Strip the Windows extended-length prefix from a symlink target.
``os.readlink`` on Windows reports absolute targets in extended-length
form (``\\\\?\\C:\\...`` or ``\\\\?\\UNC\\server\\share``). Recorded targets
are surfaced in workspace-change events and compared against ordinary
paths, so keep the plain spelling.
On POSIX this is a provable identity: the strip only applies on Windows
hosts. ``readlink(2)`` returns the literal string the link was created
with, and backslash is a valid filename byte on Linux — a target string
that merely starts with ``\\\\?\\`` there must be recorded verbatim.
On Windows, only extended *drive-letter* paths are stripped. Other
``\\\\?\\`` namespace forms (volume-GUID paths, device paths) are kept
verbatim: stripping them would leave a relative-looking remainder that
no longer names the target's namespace.
"""
if os.name != "nt":
return target
if target.startswith("\\\\?\\UNC\\"):
return "\\\\" + target[len("\\\\?\\UNC\\") :]
if target.startswith("\\\\?\\") and len(target) >= 7 and target[4].isascii() and target[4].isalpha() and target[5] == ":" and target[6] in "\\/":
return target[4:]
return target
def _snapshot_symlink(root: WorkspaceRoot, host_file: Path) -> FileSnapshot | None:
# Deliberately never follows the link (no read_bytes()/open() on the target):
# the target may point anywhere on the host, including outside the scanned
# root, so stat'ing or reading through it here would risk exposing arbitrary
# host file content/metadata as if it belonged to the workspace.
try:
stat = host_file.lstat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
relative = host_file.relative_to(root.host_path).as_posix()
virtual_path = f"{root.virtual_prefix}/{relative}"
sensitive = is_sensitive_workspace_path(virtual_path)
except OSError:
return None
try:
target = os.readlink(host_file)
except OSError:
target = None
else:
target = _normalize_symlink_target(target)
return FileSnapshot(
path=virtual_path,
root=root.name,
size=size,
mtime_ns=mtime_ns,
sha256=None,
binary=False,
sensitive=sensitive,
text=None,
content_unavailable_reason="symlink",
symlink=True,
symlink_target=target,
)
def _publish_text_atomically(path: Path, content: str) -> None:
"""Publish text through a temporary file so a partial write cannot survive.
``newline="\\n"`` keeps the publication byte-exact on every host: the
default text-mode translation would rewrite a decoded CRLF snapshot's
``\\n`` characters into ``os.linesep`` on Windows, turning ``\\r\\n``
content into ``\\r\\r\\n`` bytes that every later reader serves verbatim
as the file's text.
"""
fd, temporary = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
except BaseException:
try:
Path(temporary).unlink(missing_ok=True)
except OSError:
pass
raise
def _cache_text_file(text: str, virtual_path: str, cache_dir: Path) -> Path:
"""Cache decoded text atomically; the caller serves this path as file content."""
cache_name = hashlib.sha256(virtual_path.encode("utf-8")).hexdigest()
target = cache_dir / cache_name
_publish_text_atomically(target, text)
return target
def _read_sample(path: Path) -> bytes:
with path.open("rb") as file:
return file.read(SAMPLE_BYTES)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _decode_text_bytes(data: bytes) -> str | None:
for encoding in ("utf-8-sig", "utf-8"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
if data.startswith(_UTF16_BOMS):
try:
return data.decode("utf-16")
except UnicodeDecodeError:
return None
return None
def _sample_decodes_as_text(sample: bytes, encoding: str) -> bool:
try:
decoder = getincrementaldecoder(encoding)()
decoder.decode(sample, final=False)
except UnicodeDecodeError:
return False
return True
def _looks_binary(sample: bytes) -> bool:
if sample.startswith(_UTF16_BOMS) and _sample_decodes_as_text(sample, "utf-16"):
return False
if b"\x00" in sample:
return True
if _sample_decodes_as_text(sample, "utf-8"):
return False
return True