GitNexus/eval/tests/test_task_assets.py
Gergo Magyar c723d420d5 fix(eval): size the buffered-fallback budget for the real graph index
trusted_gitnexus_runtime_mounts and sanitized_graph both hand the ~290 MiB
graph index through TaskAssetSnapshot.materialize(), which reflinks into
each arm clone or falls back to a buffered copy under a 16 MiB budget.
Neither ext4 (CI runners) nor 9p (this container) support FICLONE, so
every real materialization fell to the buffered path and blew the budget
instantly (CI run 29750271566).

Raises MAX_BUFFERED_FALLBACK_BYTES to 512 MiB - well above the real index
size, still a full 4x below MAX_TASK_ASSET_BYTES so a genuinely oversized
declaration still fails closed.
2026-07-20 14:42:50 +00:00

412 lines
17 KiB
Python

"""Copy-on-write task-asset snapshot contracts."""
from __future__ import annotations
import os
import stat
import subprocess
from pathlib import Path
import pytest
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.oracle_assets import TaskOracleSnapshot
from workflow_bench.runner_tasks import resolve_task_bindings
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets
from workflow_bench import task_assets
SHA = "a" * 40
def _repo_and_task(tmp_path: Path, files: dict[str, bytes]) -> tuple[Path, dict[str, object]]:
repo = tmp_path / "repo"
repo.mkdir()
for relative, payload in files.items():
target = repo / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
return repo, {"sandbox_copy": sorted(files), "sandbox_dependencies": []}
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
text=True,
).stdout.strip()
def test_snapshot_is_reused_frozen_and_isolates_arm_writes(monkeypatch, tmp_path: Path) -> None:
repo, task = _repo_and_task(tmp_path, {"assets/index": b"original"})
clone_a = tmp_path / "clone-a"
clone_b = tmp_path / "clone-b"
clone_a.mkdir()
clone_b.mkdir()
reflink_calls: list[tuple[int, int]] = []
def fake_reflink(source: int, destination: int) -> bool:
reflink_calls.append((source, destination))
while chunk := os.read(source, 1024):
os.write(destination, chunk)
return True
monkeypatch.setattr(task_assets, "_try_reflink", fake_reflink)
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 0)
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
assert cache.prepare(task, repo=repo, resolved_sha=SHA) is snapshot
snapshot_file = snapshot.root / "sandbox-copy" / "assets" / "index"
assert stat.S_IMODE(snapshot.root.stat().st_mode) == 0o500
assert stat.S_IMODE(snapshot_file.stat().st_mode) == 0o400
stage_task_assets(task, repo=repo, clone=clone_a, snapshot=snapshot)
stage_task_assets(task, repo=repo, clone=clone_b, snapshot=snapshot)
(clone_a / "assets" / "index").write_bytes(b"arm-a")
assert snapshot_file.read_bytes() == b"original"
assert (clone_b / "assets" / "index").read_bytes() == b"original"
assert (clone_a / "assets" / "index").stat().st_ino != snapshot_file.stat().st_ino
assert (clone_b / "assets" / "index").stat().st_ino != snapshot_file.stat().st_ino
assert len(reflink_calls) == 2
def test_snapshot_digest_binds_content_declaration_repo_and_sha(tmp_path: Path) -> None:
repo, task = _repo_and_task(tmp_path, {"one": b"1", "two": b"2"})
with TaskAssetCache(tmp_path / "cache-a") as cache:
original = cache.prepare(task, repo=repo, resolved_sha=SHA)
other_sha = cache.prepare(task, repo=repo, resolved_sha="b" * 40)
reordered = cache.prepare(
{"sandbox_copy": ["two", "one"]},
repo=repo,
resolved_sha=SHA,
)
with TaskAssetCache(tmp_path / "cache-b") as cache:
identical = cache.prepare(task, repo=repo, resolved_sha=SHA)
(repo / "one").write_bytes(b"changed")
with TaskAssetCache(tmp_path / "cache-c") as cache:
changed = cache.prepare(task, repo=repo, resolved_sha=SHA)
assert identical.digest == original.digest
assert identical.manifest_digest == original.manifest_digest
assert other_sha.digest != original.digest
assert other_sha.manifest_digest == original.manifest_digest
assert reordered.digest != original.digest
assert changed.digest != original.digest
assert changed.manifest_digest != original.manifest_digest
def test_small_assets_use_a_bounded_buffered_fallback(monkeypatch, tmp_path: Path) -> None:
repo, task = _repo_and_task(tmp_path, {"first": b"abc", "second": b"def"})
clone = tmp_path / "clone"
clone.mkdir()
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 6)
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
snapshot.materialize(clone)
assert (clone / "first").read_bytes() == b"abc"
assert (clone / "second").read_bytes() == b"def"
def test_default_buffered_fallback_budget_covers_a_realistic_large_asset(
monkeypatch,
tmp_path: Path,
) -> None:
# 20 MiB exceeds the old 16 MiB default but must fit comfortably under
# the current default, proving the real (non-monkeypatched) budget
# constant is sized for a realistic large sandbox_copy asset such as the
# harness's own pre-built graph index, not just tiny fixtures.
payload = os.urandom(20 * 1024 * 1024)
repo, task = _repo_and_task(tmp_path, {"large": payload})
clone = tmp_path / "clone"
clone.mkdir()
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
snapshot.materialize(clone)
assert (clone / "large").read_bytes() == payload
def test_large_asset_without_reflink_fails_before_publish_and_cleans_staging(
monkeypatch,
tmp_path: Path,
) -> None:
repo, task = _repo_and_task(tmp_path, {"large": b"12345"})
clone = tmp_path / "clone"
clone.mkdir()
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 4)
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
with pytest.raises(SandboxError, match="cannot reflink"):
snapshot.materialize(clone)
assert not (clone / "large").exists()
assert not list(tmp_path.glob(".wfbench-assets-*"))
@pytest.mark.skipif(os.name == "nt", reason="symlink and FIFO contracts are POSIX-only")
@pytest.mark.parametrize("kind", ["symlink", "fifo"])
def test_snapshot_rejects_links_and_special_files(tmp_path: Path, kind: str) -> None:
repo = tmp_path / "repo"
assets = repo / "assets"
assets.mkdir(parents=True)
if kind == "symlink":
(repo / "outside").write_text("secret")
(assets / "bad").symlink_to(repo / "outside")
else:
os.mkfifo(assets / "bad")
with TaskAssetCache(tmp_path / "cache") as cache:
with pytest.raises(SandboxError, match="regular files and directories|symlink"):
cache.prepare({"sandbox_copy": ["assets"]}, repo=repo, resolved_sha=SHA)
def test_snapshot_rejects_a_file_mutated_during_capture(monkeypatch, tmp_path: Path) -> None:
repo, task = _repo_and_task(tmp_path, {"asset": b"original"})
original_read = task_assets._read_source_chunk
changed = False
def mutate_after_first_read(descriptor: int, size: int) -> bytes:
nonlocal changed
chunk = original_read(descriptor, size)
if chunk and not changed:
changed = True
with (repo / "asset").open("ab") as source:
source.write(b"!")
return chunk
monkeypatch.setattr(task_assets, "_read_source_chunk", mutate_after_first_read)
with TaskAssetCache(tmp_path / "cache") as cache:
with pytest.raises(SandboxError, match="changed while snapshotting"):
cache.prepare(task, repo=repo, resolved_sha=SHA)
@pytest.mark.parametrize(
("limit", "value", "task_files", "message"),
[
("MAX_TASK_ASSET_ENTRIES", 1, {"nested/file": b"x"}, "entry limit"),
("MAX_TASK_ASSET_PATH_BYTES", 4, {"long-name": b"x"}, "path byte limit"),
("MAX_TASK_ASSET_BYTES", 4, {"asset": b"12345"}, "total byte limit"),
],
)
def test_snapshot_enforces_hard_walk_limits(
monkeypatch,
tmp_path: Path,
limit: str,
value: int,
task_files: dict[str, bytes],
message: str,
) -> None:
repo, task = _repo_and_task(tmp_path, task_files)
monkeypatch.setattr(task_assets, limit, value)
with TaskAssetCache(tmp_path / "cache") as cache:
with pytest.raises(SandboxError, match=message):
cache.prepare(task, repo=repo, resolved_sha=SHA)
def test_snapshot_rejects_overlapping_declarations(tmp_path: Path) -> None:
repo, _task = _repo_and_task(tmp_path, {"assets/index": b"index"})
with TaskAssetCache(tmp_path / "cache") as cache:
with pytest.raises(SandboxError, match="overlap"):
cache.prepare(
{"sandbox_copy": ["assets", "assets/index"]},
repo=repo,
resolved_sha=SHA,
)
def test_directory_materialization_removes_stale_children_exactly(tmp_path: Path) -> None:
repo, task = _repo_and_task(tmp_path, {"assets/current": b"captured"})
task["sandbox_copy"] = ["assets"]
clone = tmp_path / "clone"
(clone / "assets" / "nested").mkdir(parents=True)
(clone / "assets" / "current").write_bytes(b"old")
(clone / "assets" / "stale").write_bytes(b"stale")
(clone / "assets" / "nested" / "stale").write_bytes(b"stale")
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
snapshot.materialize(clone)
assert sorted(path.relative_to(clone).as_posix() for path in clone.rglob("*")) == [
"assets",
"assets/current",
]
assert (clone / "assets" / "current").read_bytes() == b"captured"
@pytest.mark.parametrize("source_kind", ["file", "directory"])
def test_materialization_replaces_file_directory_type_conflicts(
tmp_path: Path,
source_kind: str,
) -> None:
if source_kind == "file":
repo, task = _repo_and_task(tmp_path, {"asset": b"file"})
else:
repo, task = _repo_and_task(tmp_path, {"asset/child": b"directory"})
task["sandbox_copy"] = ["asset"]
clone = tmp_path / "clone"
clone.mkdir()
if source_kind == "file":
(clone / "asset").mkdir()
(clone / "asset" / "stale").write_bytes(b"stale")
else:
(clone / "asset").write_bytes(b"stale file")
with TaskAssetCache(tmp_path / "cache") as cache:
cache.prepare(task, repo=repo, resolved_sha=SHA).materialize(clone)
if source_kind == "file":
assert (clone / "asset").is_file()
assert (clone / "asset").read_bytes() == b"file"
else:
assert (clone / "asset").is_dir()
assert (clone / "asset" / "child").read_bytes() == b"directory"
@pytest.mark.skipif(os.name == "nt", reason="symlink containment is POSIX-only")
def test_exact_tree_removal_does_not_follow_stale_child_symlinks(tmp_path: Path) -> None:
repo, task = _repo_and_task(tmp_path, {"assets/current": b"captured"})
task["sandbox_copy"] = ["assets"]
clone = tmp_path / "clone"
outside = tmp_path / "outside"
(clone / "assets").mkdir(parents=True)
outside.mkdir()
(outside / "canary").write_bytes(b"outside")
(clone / "assets" / "stale-link").symlink_to(outside, target_is_directory=True)
with TaskAssetCache(tmp_path / "cache") as cache:
cache.prepare(task, repo=repo, resolved_sha=SHA).materialize(clone)
assert (outside / "canary").read_bytes() == b"outside"
assert not (clone / "assets" / "stale-link").exists()
def test_dependency_snapshot_mounts_bound_bytes_and_rejects_later_live_drift(tmp_path: Path) -> None:
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
task = {
"sandbox_copy": [],
"sandbox_dependencies": [{"source": "dependency", "target": "node_modules/dependency"}],
}
clone = tmp_path / "clone"
clone.mkdir()
with TaskAssetCache(tmp_path / "cache-a") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
binding = snapshot.dependency_binding
mounts = stage_task_assets(task, repo=repo, clone=clone, snapshot=snapshot)
assert mounts[0].source != (repo / "dependency").resolve()
assert (mounts[0].source / "package.json").read_bytes() == b'{"version":1}'
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
with TaskAssetCache(tmp_path / "cache-b") as cache:
with pytest.raises(SandboxError, match="changed after task binding"):
cache.prepare(
task,
repo=repo,
resolved_sha=SHA,
expected_dependency_binding=binding,
)
def test_dependency_content_and_manifest_digests_bind_distinct_contracts(tmp_path: Path) -> None:
repo, _ = _repo_and_task(tmp_path, {"dependency/file": b"one"})
task = {
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
}
retargeted = {
"sandbox_dependencies": [{"source": "dependency", "target": "vendor/dependency"}],
}
with TaskAssetCache(tmp_path / "cache-a") as cache:
original = cache.prepare(task, repo=repo, resolved_sha=SHA)
changed_target = cache.prepare(retargeted, repo=repo, resolved_sha=SHA)
assert changed_target.dependency_content_digest == original.dependency_content_digest
assert changed_target.dependency_manifest_digest != original.dependency_manifest_digest
(repo / "dependency" / "file").write_bytes(b"two")
with TaskAssetCache(tmp_path / "cache-b") as cache:
changed_content = cache.prepare(task, repo=repo, resolved_sha=SHA)
assert changed_content.dependency_content_digest != original.dependency_content_digest
assert changed_content.dependency_manifest_digest != original.dependency_manifest_digest
@pytest.mark.skipif(os.name == "nt", reason="dependency symlink fixtures are POSIX-only")
def test_dependency_snapshot_preserves_internal_symlinks_and_executable_files(tmp_path: Path) -> None:
repo, _ = _repo_and_task(tmp_path, {"dependency/package/bin": b"#!/bin/sh\nexit 0\n"})
executable = repo / "dependency" / "package" / "bin"
executable.chmod(0o755)
(repo / "dependency" / ".bin").mkdir()
(repo / "dependency" / ".bin" / "tool").symlink_to("../package/bin")
task = {
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
}
clone = tmp_path / "clone"
clone.mkdir()
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
mount = stage_task_assets(task, repo=repo, clone=clone, snapshot=snapshot)[0]
link = mount.source / ".bin" / "tool"
captured_executable = mount.source / "package" / "bin"
assert link.is_symlink()
assert os.readlink(link) == "../package/bin"
assert stat.S_IMODE(captured_executable.stat().st_mode) == 0o500
@pytest.mark.skipif(os.name == "nt", reason="dependency symlink fixtures are POSIX-only")
def test_dependency_snapshot_rejects_links_that_escape_the_sandbox_workspace(tmp_path: Path) -> None:
repo, _ = _repo_and_task(tmp_path, {"dependency/kept": b"payload"})
(repo / "dependency" / "escape").symlink_to("../../../../outside")
task = {
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
}
with TaskAssetCache(tmp_path / "cache") as cache:
with pytest.raises(SandboxError, match="escapes the sandbox workspace"):
cache.prepare(task, repo=repo, resolved_sha=SHA)
def test_resolved_task_binding_carries_dependency_digests_and_rejects_live_drift(tmp_path: Path) -> None:
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
_git(repo, "init", "--quiet")
_git(repo, "config", "user.name", "Workflow Bench Test")
_git(repo, "config", "user.email", "workflow-bench@example.invalid")
_git(repo, "add", ".")
_git(repo, "commit", "--quiet", "-m", "fixture")
task = {
"id": "dependency-binding",
"class": "test",
"repo": str(repo),
"prompt": "inspect dependency",
"verify": "true",
"sandbox_copy": [],
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
}
oracle = TaskOracleSnapshot(
command="true",
command_digest="1" * 64,
manifest_digest="2" * 64,
digest="3" * 64,
files=(),
)
with TaskAssetCache(tmp_path / "binding-cache") as cache:
binding = resolve_task_bindings(
[task],
oracle_snapshots=[oracle],
task_asset_cache=cache,
)[0]
assert len(binding["sandbox_dependency_content_digest"]) == 64
assert len(binding["sandbox_dependency_manifest_digest"]) == 64
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
with pytest.raises(ValueError, match="definition drifted"):
resolve_task_bindings([task], [binding], oracle_snapshots=[oracle])